leerness 1.36.176 → 1.36.178

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/lib/drift.js CHANGED
@@ -1,360 +1,525 @@
1
- // lib/drift.js — drift check 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 7번째, 1.9.422)
2
- // bin/leerness.js 에서 driftCheckCmd(322줄) 분리. DI: harness 고유 의존(VERSION, has, arg, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency) 주입.
3
- // io 프리미티브는 ./io, cp/path 빌트인. 내부 재귀(auto-fix 후 재검사)는 deps 전달. 동작/출력 무변경.
4
- 'use strict';
5
- const cp = require('child_process');
6
- const path = require('path');
7
- const fs = require('fs');
8
- const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
9
-
10
- // 1.36.140: git 실행은 `lib/git.js` **단일** 초크포인트를 쓴다.
11
- // 이 파일에 복제해 두었던 함수는 환경을 걷어내지 않아, 상속된 `GIT_DIR` 아래에서
12
- // 아래 `branch -d` 가 **다른 저장소의 브랜치를 지웠다**(실측 60 → 10, 대조군 35 → 35).
13
- const _gitSpawn = require('./git').gitSpawn;
14
- function driftCheckCmd(root, opts = {}, deps = {}) {
15
- const { VERSION, has, arg, uiLang, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath, envDiff, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency } = deps;
16
- root = absRoot(root || process.cwd());
17
- const t = (ko, en) => (uiLang === 'en' ? en : ko); // 1.27.2 (UR-0010 Phase 10): drift 출력 영어 opt-in (--auto-fix 진행로그는 Phase 10b)
18
- // 1.9.434 (11th 외부평가 Opus P2, UR-0136): 미존재 경로는 healthy 위조 금지 — failJson + exit 1.
19
- if (!exists(root)) { failJson(has('--json'), 'path_not_found', t(`경로 없음: ${root}`, `path not found: ${root}`)); return; }
20
- const now = Date.now();
21
- const _ageDays = (p) => {
22
- if (!exists(p)) return null;
23
- return (now - fs.statSync(p).mtimeMs) / 86400000;
24
- };
25
- // 각 메타파일의 마지막 갱신
26
- const signals = [];
27
- // 1. session-handoff.md - "Last generated" 라인 우선, 없으면 mtime
28
- const shPath = handoffPath(root);
29
- if (exists(shPath)) {
30
- const txt = read(shPath);
31
- // 1.9.316 (drift 마커 버그): 최신(마지막) 'Last generated' 사용 — 구 블록 중복 시 첫(구) 매치를 읽던 오발화 방어.
32
- const allGen = [...txt.matchAll(/Last generated:\s*([\d\-T:.Z]+)/g)];
33
- const m = allGen.length ? allGen[allGen.length - 1] : null;
34
- let ageDays;
35
- if (m) {
36
- // 1.36.50 (NaN 날짜 클래스 스윕): 손상 타임스탬프(NaN)는 신선함을 증명 못 함 — mtime 폴백(무언 fresh 오판 방지)
37
- ageDays = (now - new Date(m[1]).getTime()) / 86400000;
38
- if (!Number.isFinite(ageDays)) ageDays = _ageDays(shPath);
39
- } else {
40
- ageDays = _ageDays(shPath);
41
- }
42
- signals.push({ file: 'session-handoff.md', ageDays, threshold: 1, weight: 30, label: t('session close 누락', 'session close missing') });
43
- }
44
- // 2. current-state.md - "Updated: YYYY-MM-DD" 라인
45
- const csPath = currentStatePath(root);
46
- if (exists(csPath)) {
47
- const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
48
- let ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
49
- if (!Number.isFinite(ageDays)) ageDays = _ageDays(csPath); // 1.36.50: NaN 날짜 → mtime 폴백
50
- signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: t('current-state 갱신 없음', 'current-state not updated') });
51
- }
52
- // 3. progress-tracker.md 마지막 row의 updated 컬럼
53
- const rows = readProgressRows(root);
54
- if (rows.length) {
55
- const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]).filter(d => Number.isFinite(new Date(d).getTime())); // 1.36.50: 무효 날짜("2026-99-99") 제외
56
- if (dates.length) {
57
- dates.sort();
58
- const latest = dates[dates.length - 1];
59
- const ageDays = (now - new Date(latest).getTime()) / 86400000;
60
- signals.push({ file: 'progress-tracker.md', ageDays, threshold: 1, weight: 30, label: t('task update 없음', 'no task update') });
61
- }
62
- } else {
63
- signals.push({ file: 'progress-tracker.md', ageDays: 999, threshold: 1, weight: 25, label: t('progress-tracker 비어있음', 'progress-tracker empty') });
64
- }
65
- // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
66
- const tlPath = taskLogPath(root);
67
- if (exists(tlPath)) {
68
- const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]).filter(d => Number.isFinite(new Date(d).getTime())); // 1.36.50: 무효 날짜 제외
69
- if (dates.length) {
70
- dates.sort();
71
- const latest = dates[dates.length - 1];
72
- const ageDays = (now - new Date(latest).getTime()) / 86400000;
73
- signals.push({ file: 'task-log.md', ageDays, threshold: 2, weight: 20, label: t('task-log 갱신 없음', 'task-log not updated') });
74
- }
75
- }
76
- // 점수 계산
77
- let totalScore = 0;
78
- const fired = [];
79
- for (const s of signals) {
80
- if (s.ageDays > s.threshold) {
81
- totalScore += s.weight;
82
- fired.push(s);
83
- }
84
- }
85
- // 1.9.78: 보안 신호 (env / .gitignore 누락) — 5번째 신호
86
- try {
87
- const envPath = path.join(root, '.env');
88
- if (exists(envPath)) {
89
- let secScore = 0;
90
- const secIssues = [];
91
- // (a) .env vs .env.example 동기화
92
- try {
93
- const d = envDiff(root);
94
- if (d.inEnvOnly.length) {
95
- secIssues.push(t(`.env→.env.example 누락 ${d.inEnvOnly.length}건`, `.env→.env.example missing ${d.inEnvOnly.length}`));
96
- secScore += 15;
97
- }
98
- } catch {}
99
- // (b) .gitignore 시크릿 패턴
100
- try {
101
- const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
102
- const giLines = giText.split('\n').map(l => l.trim());
103
- const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
104
- const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
105
- if (missing.length) {
106
- secIssues.push(t(`.gitignore 시크릿 누락 ${missing.length}건`, `.gitignore missing secret patterns ${missing.length}`));
107
- // 누락이 .env 자체면 최우선 위험 — 15점 가중
108
- if (missing.includes('.env')) secScore += 30;
109
- else secScore += Math.min(20, missing.length * 5);
110
- }
111
- } catch {}
112
- if (secScore > 0) {
113
- totalScore += secScore;
114
- fired.push({ file: '.env / .gitignore', ageDays: null, threshold: 0, weight: secScore, label: t(`보안 위험 (1.9.78): ${secIssues.join(' · ')}`, `security risk: ${secIssues.join(' · ')}`) });
115
- }
116
- }
117
- } catch {}
118
- // 1.9.143: Feature Graph 미사용 신호 — 노드는 있는데 edges 비율 낮으면 인과관계 정리 미진
119
- try {
120
- const { nodes: fGraphNodes } = _readFeatureGraph(root);
121
- if (fGraphNodes.length >= 3) {
122
- const edgeCount = fGraphNodes.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
123
- const linkedSet = new Set();
124
- for (const n of fGraphNodes) {
125
- for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedSet.add(n.id); linkedSet.add(x); }
126
- }
127
- const isolatedCount = Math.max(0, fGraphNodes.length - linkedSet.size);
128
- const isolatedRatio = isolatedCount / fGraphNodes.length;
129
- if (edgeCount === 0 || isolatedRatio >= 0.5) {
130
- const fgScore = edgeCount === 0 ? 25 : 15;
131
- totalScore += fgScore;
132
- fired.push({ file: '.leerness/feature-graph.md', ageDays: null, threshold: 0, weight: fgScore, label: t(`Feature Graph 미정리 (1.9.143): ${fGraphNodes.length} 노드, edges=${edgeCount}, isolated=${isolatedCount}`, `Feature Graph unlinked: ${fGraphNodes.length} nodes, edges=${edgeCount}, isolated=${isolatedCount}`) });
133
- }
134
- }
135
- } catch {}
136
- // 신규 _apps/* 에서 task 0건도 신호로
137
- const appsDir = path.join(root, '_apps');
138
- let appsZeroTask = [];
139
- if (exists(appsDir)) {
140
- for (const d of fs.readdirSync(appsDir)) {
141
- const sub = path.join(appsDir, d);
142
- if (!exists(path.join(sub, '.leerness'))) continue;
143
- const subRows = readProgressRows(sub);
144
- if (!subRows.length) appsZeroTask.push(d);
145
- }
146
- if (appsZeroTask.length) {
147
- const w = Math.min(50, appsZeroTask.length * 10);
148
- totalScore += w;
149
- fired.push({ file: `_apps/* (${appsZeroTask.length}개)`, ageDays: null, threshold: 0, weight: w, label: t(`task 0건 sub-app: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}`, `sub-app with 0 tasks: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}`) });
150
- }
151
- }
152
- // 레벨 판정
153
- let level = '🟢 healthy';
154
- if (totalScore >= 100) level = '🔴 critical';
155
- else if (totalScore >= 50) level = '🟡 warning';
156
- else if (totalScore >= 20) level = '🟠 attention';
157
-
158
- // 1.9.38 (D): drift critical 등급은 누적 카운트 (학습 신호)
159
- try {
160
- if (level === '🔴 critical') {
161
- const stats = _readUsageStats(root);
162
- stats.drift = stats.drift || {};
163
- stats.drift.criticalSeen = (stats.drift.criticalSeen || 0) + 1;
164
- const p = _usageStatsPath(root);
165
- mkdirp(path.dirname(p));
166
- writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
167
- }
168
- } catch {}
169
- // 1.9.39: --auto-fix — critical 시 session close 자동 실행
170
- // 1.9.82: --auto-fix가 보안 신호도 자동 회복 (audit --fix 호출)
171
- // 1.9.432 (10th 외부평가 Opus latent, UR-0131 잔여): depth 가드 — 재귀 호출(_noAutoFix)은 auto-fix 재진입 금지.
172
- // 기존엔 autoFix=has('--auto-fix')가 전역 argv 재독→재귀도 auto-fix 분기 재진입, 종료는 'audit이 보안신호를 지운다'는 취약 불변식에 의존(미래 신호 타입이 비가역이면 무한재귀). 명시 1회 보장.
173
- const autoFix = has('--auto-fix') && !opts._noAutoFix;
174
- // 1.9.439 (10th 외부평가 Codex P1, UR-0135): --json 모드면 auto-fix 진행로그 억제(stdout 순수 JSON 보장).
175
- // 재귀(_noAutoFix)는 auto-fix 블록을 건너뛰고 마지막 JSON(아래 has('--json') 블록)만 출력 → afLog 로 첫 패스 진행로그만 무음화.
176
- const afLog = has('--json') ? () => {} : log;
177
- // 1.9.82: 보안 신호가 fired에 있으면 우선 audit --fix 호출
178
- // 1.28.1 (Phase 10b): 보안 신호 판정을 *언어-안정* 한 file 필드로 — 1.27.2 에서 label 을 언어화하면서 한국어 라벨 regex 매칭이 --language en 에서 깨지던 버그 수정(보안 auto-fix 미발동 방지).
179
- const hasSecurityFired = fired.some(f => f.file === '.env / .gitignore');
180
- if (autoFix && hasSecurityFired) {
181
- afLog('');
182
- afLog(t(`🔒 --auto-fix 활성 (1.9.82) — 보안 신호 회복: audit --fix 자동 실행 중...`, `🔒 --auto-fix on — recovering security signal: running audit --fix...`));
183
- try {
184
- const r = cp.spawnSync(process.execPath, [harnessPath, 'audit', root, '--fix'],
185
- { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
186
- if (r.status === 0) {
187
- afLog(t(`✓ audit --fix 완료 — .gitignore + .env.example 동기화`, `✓ audit --fix done — .gitignore + .env.example synced`));
188
- // 재검사 (보안 신호 회복 확인)
189
- afLog('');
190
- afLog(t(`재검사 중...`, `re-checking...`));
191
- return driftCheckCmd(root, { ...opts, _noAutoFix: true }, deps); // 재귀 1회 (auto-fix 없이, 1.9.432 depth 가드)
192
- } else {
193
- afLog(t(`⚠ audit --fix 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`, `⚠ audit --fix failed (exit ${r.status}) — run \`leerness audit --fix\` manually`));
194
- }
195
- } catch (e) {
196
- afLog(t(`⚠ auto-fix 보안 회복 오류: ${e.message}`, `⚠ auto-fix security recovery error: ${e.message}`));
197
- }
198
- }
199
- // 1.9.242: drift check --auto-fix 에 env encoding BOM 자동 추가 통합 (사용자 명시 UR-0014 2단계)
200
- // 1.9.82 패턴 확장 — drift 회복 시 셸 스크립트 인코딩 위험도 자동 해결
201
- if (autoFix) {
202
- try {
203
- const encScan = _scanShellScriptsEncoding(root);
204
- if (encScan.atRisk && encScan.atRisk.length > 0) {
205
- afLog('');
206
- afLog(t(`🌐 --auto-fix 활성 (1.9.242) — 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 BOM 자동 추가 중...`, `🌐 --auto-fix on — adding UTF-8 BOM to ${encScan.atRisk.length} at-risk shell script(s)...`));
207
- let ok = 0;
208
- for (const r of encScan.atRisk) {
209
- try {
210
- const fullPath = path.join(root, r.file);
211
- const orig = fs.readFileSync(fullPath);
212
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
213
- const fixed = Buffer.concat([bom, orig]);
214
- fs.writeFileSync(fullPath, fixed);
215
- ok++;
216
- } catch {}
217
- }
218
- afLog(t(`✓ UTF-8 BOM 추가 ${ok}/${encScan.atRisk.length}건 (1.9.242 UR-0014)`, `✓ added UTF-8 BOM ${ok}/${encScan.atRisk.length}`));
219
- }
220
- } catch (e) {
221
- afLog(t(`⚠ env encoding auto-fix 오류 (1.9.242): ${e.message}`, `⚠ env encoding auto-fix error: ${e.message}`));
222
- }
223
- }
224
- // 1.9.225: drift check --auto-fix 에 delivered 패턴 자동 적용 통합 (1.9.223/224 시스템 회수)
225
- // 사용자 요청에 "구현 완료" 패턴이 누적되면 가짜 미답 신호가 drift score 를 가중시킬 수 있음 → 자동 정리.
226
- // 1.9.82 audit --fix 패턴과 동일: --auto-fix 시 즉시 적용, 적용 후 재검사.
227
- if (autoFix) {
228
- try {
229
- const delivered = _detectDeliveredRequests(root);
230
- if (delivered.candidates && delivered.candidates.length > 0) {
231
- afLog('');
232
- afLog(t(`📥 --auto-fix 활성 (1.9.225) — delivered 패턴 ${delivered.candidates.length}건 자동 완료 중...`, `📥 --auto-fix on — auto-completing ${delivered.candidates.length} delivered pattern(s)...`));
233
- let ok = 0;
234
- for (const c of delivered.candidates) {
235
- const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'drift-auto-fix-1.9.225' });
236
- if (u) ok++;
237
- }
238
- afLog(t(`✓ delivered 자동 완료 ${ok}/${delivered.candidates.length}건`, `✓ delivered auto-completed ${ok}/${delivered.candidates.length}`));
239
- }
240
- } catch (e) {
241
- afLog(t(`⚠ delivered auto-apply 오류 (1.9.225): ${e.message}`, `⚠ delivered auto-apply error: ${e.message}`));
242
- }
243
- }
244
- // 1.9.293: drift check --auto-fix 에 idempotency task/user-request 중복 자동 정리 통합
245
- // 누적 중복 task/요청이 idempotency 위반(medium)을 가중 → drift/handoff 노이즈. 안전: 완전중복 행 제거 + 동일텍스트 dropped 보존(id 유지).
246
- if (autoFix) {
247
- try {
248
- const idemFixes = _autoFixIdempotency(root);
249
- const totalFixed = idemFixes.reduce((n, f) => n + (f.removedExact || 0) + (f.droppedSameText || 0) + (f.count || 0), 0);
250
- // 1.36.131 (검수 P1): 실패 항목은 수치 합이 0 이라 종전엔 **조용히 지나갔다** — 중복이 남았는데 아무 말도 안 했다.
251
- // 수치와 무관하게 실패는 말한다(이 명령은 회복 경로라 exit 를 바꾸지 않는다 — 대신 반드시 보인다).
252
- const idemFailed = idemFixes.filter(f => f.action === 'error');
253
- if (idemFailed.length) {
254
- afLog('');
255
- afLog(t(`❌ idempotency auto-fix 실패 ${idemFailed.length}건 — 중복이 남아 있다: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`,
256
- `❌ idempotency auto-fix failed ${idemFailed.length} — duplicates remain: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`));
257
- }
258
- if (totalFixed > 0) {
259
- afLog('');
260
- afLog(t(`🔁 --auto-fix 활성 (1.9.293) — idempotency 중복 ${totalFixed}건 자동 정리 (task/user-request dedup)`, `🔁 --auto-fix on — deduped ${totalFixed} idempotency duplicate(s) (task/user-request)`));
261
- }
262
- } catch (e) {
263
- afLog(t(`⚠ idempotency auto-fix 오류 (1.9.293): ${e.message}`, `⚠ idempotency auto-fix error: ${e.message}`));
264
- }
265
- }
266
- // 1.9.236: drift check --auto-fix 에 release cleanup 통합 (1.9.235 회수)
267
- // 누적된 50개+ release/* branches → abnormal-shutdown release-branch-pending 신호 가중
268
- // 안전: keep 10 (최근 10개 유지), merged 만 삭제 (1.9.235 안전 가드)
269
- // 임계: 50개 초과 시만 자동 정리 (소량 누적은 정상 운영)
270
- if (autoFix) {
271
- try {
272
- const branchR = _gitSpawn(['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
273
- if (branchR.status === 0) {
274
- const merged = (branchR.stdout || '').split('\n')
275
- .map(l => l.replace(/^\*?\s+/, '').trim())
276
- .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
277
- if (merged.length > 50) {
278
- afLog('');
279
- afLog(t(`🗑 --auto-fix 활성 (1.9.236) — release/* merged ${merged.length}개 (50+) 자동 정리 (keep 10)...`, `🗑 --auto-fix on — cleaning up ${merged.length} merged release/* branches (50+, keep 10)...`));
280
- // 정렬 (semver desc)
281
- merged.sort((a, b) => {
282
- const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
283
- const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
284
- for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
285
- return 0;
286
- });
287
- const currentBranchR = _gitSpawn(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
288
- const currentBranch = (currentBranchR.stdout || '').trim();
289
- const toDelete = merged.slice(10).filter(b => b !== currentBranch);
290
- let ok = 0;
291
- for (const b of toDelete) {
292
- const r = _gitSpawn(['branch', '-d', b], { cwd: root, encoding: 'utf8' });
293
- if (r.status === 0) ok++;
294
- }
295
- afLog(t(`✓ release cleanup 자동 완료 ${ok}/${toDelete.length}건 (keep 10)`, `✓ release cleanup done ${ok}/${toDelete.length} (keep 10)`));
296
- }
297
- }
298
- } catch (e) {
299
- afLog(t(`⚠ release cleanup auto-fix 오류 (1.9.236): ${e.message}`, `⚠ release cleanup auto-fix error: ${e.message}`));
300
- }
301
- }
302
- if (autoFix && level === '🔴 critical' && !hasSecurityFired) {
303
- afLog('');
304
- afLog(t(`🔧 --auto-fix 활성 — session close 자동 실행 중...`, `🔧 --auto-fix on — running session close...`));
305
- try {
306
- const r = cp.spawnSync(process.execPath, [harnessPath, 'session', 'close', root], { encoding: 'utf8', timeout: 60000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
307
- if (r.status === 0) {
308
- afLog(t(`✓ session close 자동 완료`, `✓ session close done`));
309
- // autoResolved 카운트
310
- const stats = _readUsageStats(root);
311
- stats.drift = stats.drift || {};
312
- stats.drift.autoResolved = (stats.drift.autoResolved || 0) + 1;
313
- const p = _usageStatsPath(root);
314
- mkdirp(path.dirname(p));
315
- writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
316
- // 재검사
317
- afLog('');
318
- afLog(t(`재검사 중...`, `re-checking...`));
319
- return driftCheckCmd(root, { ...opts, _noAutoFix: true }, deps); // 재귀 1회 (auto-fix 없이, 1.9.432 depth 가드)
320
- } else {
321
- afLog(t(`⚠ session close 실패 (exit ${r.status}) — 수동 실행 필요`, `⚠ session close failed (exit ${r.status}) — run manually`));
322
- }
323
- } catch (e) {
324
- afLog(t(`⚠ auto-fix 오류: ${e.message}`, `⚠ auto-fix error: ${e.message}`));
325
- }
326
- }
327
- if (has('--json')) {
328
- // 1.36.66 (8차 헌트 F4): --json 도 사람용과 exit 동치 — critical 은 exit 1 (조기 return 전에 설정)
329
- if (level === '🔴 critical') process.exitCode = 1;
330
- log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask }, null, 2));
331
- return;
332
- }
333
- log(`# leerness drift check (1.9.37)`);
334
- log(t(`경로: ${root}`, `path: ${root}`));
335
- log('');
336
- log(t(`상태: ${level} · 점수 ${totalScore}/200`, `status: ${level} · score ${totalScore}/200`));
337
- log('');
338
- log(t(`| 신호 | age | 임계 | 가중치 | 발화 |`, `| signal | age | threshold | weight | fired |`));
339
- log(`|---|---:|---:|---:|---|`);
340
- for (const s of signals) {
341
- const fire = s.ageDays > s.threshold ? '🔥' : '✓';
342
- const age = s.ageDays === null ? '-' : `${s.ageDays.toFixed(1)}d`;
343
- log(`| ${s.label} | ${age} | ${s.threshold}d | ${s.weight} | ${fire} |`);
344
- }
345
- if (appsZeroTask.length) {
346
- log('');
347
- log(t(`task 0건 sub-app (${appsZeroTask.length}개): ${appsZeroTask.join(', ')}`, `sub-apps with 0 tasks (${appsZeroTask.length}): ${appsZeroTask.join(', ')}`));
348
- }
349
- if (totalScore >= 50) {
350
- log('');
351
- log(t(`💡 권장 조치:`, `💡 recommended actions:`));
352
- log(t(` - 즉시: leerness session close . (handoff/current-state 갱신)`, ` - now: leerness session close . (refresh handoff/current-state)`));
353
- log(t(` - 또는: leerness audit . --fix (자동 갱신 가능 항목 적용)`, ` - or: leerness audit . --fix (apply auto-fixable items)`));
354
- log(t(` - sub-app에 task 등록: cd _apps/X && leerness task add "..."`, ` - add tasks to a sub-app: cd _apps/X && leerness task add "..."`));
355
- log(t(` - 이 검사 끄기: --no-drift-check 또는 LEERNESS_NO_DRIFT_CHECK=1`, ` - disable this check: --no-drift-check or LEERNESS_NO_DRIFT_CHECK=1`));
356
- }
357
- if (level === '🔴 critical') process.exitCode = 1;
358
- }
359
-
360
- module.exports = { driftCheckCmd };
1
+ // lib/drift.js — drift check 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 7번째, 1.9.422)
2
+ // bin/leerness.js 에서 driftCheckCmd(322줄) 분리. DI: harness 고유 의존(VERSION, has, arg, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency) 주입.
3
+ // io 프리미티브는 ./io, cp/path 빌트인. 내부 재귀(auto-fix 후 재검사)는 deps 전달. 동작/출력 무변경.
4
+ 'use strict';
5
+ const cp = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
9
+ const { applyShellScriptUtf8Bom } = require('./shell-encoding');
10
+
11
+ // 1.36.140: git 실행은 `lib/git.js` **단일** 초크포인트를 쓴다.
12
+ // 이 파일에 복제해 두었던 함수는 환경을 걷어내지 않아, 상속된 `GIT_DIR` 아래에서
13
+ // 아래 `branch -d` 가 **다른 저장소의 브랜치를 지웠다**(실측 60 → 10, 대조군 35 → 35).
14
+ const _gitSpawn = require('./git').gitSpawn;
15
+ function driftCheckCmd(root, opts = {}, deps = {}) {
16
+ const { VERSION, has, arg, uiLang, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath, envDiff, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency } = deps;
17
+ const gitSpawn = typeof deps._gitSpawn === 'function' ? deps._gitSpawn : _gitSpawn;
18
+ root = absRoot(root || process.cwd());
19
+ const t = (ko, en) => (uiLang === 'en' ? en : ko); // 1.27.2 (UR-0010 Phase 10): drift 출력 영어 opt-in (--auto-fix 진행로그는 Phase 10b)
20
+ // 1.9.434 (11th 외부평가 Opus P2, UR-0136): 미존재 경로는 healthy 위조 금지 — failJson + exit 1.
21
+ if (!exists(root)) { failJson(has('--json'), 'path_not_found', t(`경로 없음: ${root}`, `path not found: ${root}`)); return; }
22
+ const now = Date.now();
23
+ const _ageDays = (p) => {
24
+ if (!exists(p)) return null;
25
+ return (now - fs.statSync(p).mtimeMs) / 86400000;
26
+ };
27
+ // 각 메타파일의 마지막 갱신
28
+ const signals = [];
29
+ // 1. session-handoff.md - "Last generated" 라인 우선, 없으면 mtime
30
+ const shPath = handoffPath(root);
31
+ if (exists(shPath)) {
32
+ const txt = read(shPath);
33
+ // 1.9.316 (drift 마커 버그): 최신(마지막) 'Last generated' 사용 — 구 블록 중복 시 첫(구) 매치를 읽던 오발화 방어.
34
+ const allGen = [...txt.matchAll(/Last generated:\s*([\d\-T:.Z]+)/g)];
35
+ const m = allGen.length ? allGen[allGen.length - 1] : null;
36
+ let ageDays;
37
+ if (m) {
38
+ // 1.36.50 (NaN 날짜 클래스 스윕): 손상 타임스탬프(NaN)는 신선함을 증명 못 함 — mtime 폴백(무언 fresh 오판 방지)
39
+ ageDays = (now - new Date(m[1]).getTime()) / 86400000;
40
+ if (!Number.isFinite(ageDays)) ageDays = _ageDays(shPath);
41
+ } else {
42
+ ageDays = _ageDays(shPath);
43
+ }
44
+ signals.push({ file: 'session-handoff.md', ageDays, threshold: 1, weight: 30, label: t('session close 누락', 'session close missing') });
45
+ }
46
+ // 2. current-state.md - "Updated: YYYY-MM-DD" 라인
47
+ const csPath = currentStatePath(root);
48
+ if (exists(csPath)) {
49
+ const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
50
+ let ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
51
+ if (!Number.isFinite(ageDays)) ageDays = _ageDays(csPath); // 1.36.50: NaN 날짜 → mtime 폴백
52
+ signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: t('current-state 갱신 없음', 'current-state not updated') });
53
+ }
54
+ // 3. progress-tracker.md 마지막 row의 updated 컬럼
55
+ const rows = readProgressRows(root);
56
+ if (rows.length) {
57
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]).filter(d => Number.isFinite(new Date(d).getTime())); // 1.36.50: 무효 날짜("2026-99-99") 제외
58
+ if (dates.length) {
59
+ dates.sort();
60
+ const latest = dates[dates.length - 1];
61
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
62
+ signals.push({ file: 'progress-tracker.md', ageDays, threshold: 1, weight: 30, label: t('task update 없음', 'no task update') });
63
+ }
64
+ } else {
65
+ signals.push({ file: 'progress-tracker.md', ageDays: 999, threshold: 1, weight: 25, label: t('progress-tracker 비어있음', 'progress-tracker empty') });
66
+ }
67
+ // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
68
+ const tlPath = taskLogPath(root);
69
+ if (exists(tlPath)) {
70
+ const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]).filter(d => Number.isFinite(new Date(d).getTime())); // 1.36.50: 무효 날짜 제외
71
+ if (dates.length) {
72
+ dates.sort();
73
+ const latest = dates[dates.length - 1];
74
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
75
+ signals.push({ file: 'task-log.md', ageDays, threshold: 2, weight: 20, label: t('task-log 갱신 없음', 'task-log not updated') });
76
+ }
77
+ }
78
+ // 점수 계산
79
+ let totalScore = 0;
80
+ const fired = [];
81
+ for (const s of signals) {
82
+ if (s.ageDays > s.threshold) {
83
+ totalScore += s.weight;
84
+ fired.push(s);
85
+ }
86
+ }
87
+ // 1.9.78: 보안 신호 (env / .gitignore 누락) — 5번째 신호
88
+ try {
89
+ const envPath = path.join(root, '.env');
90
+ if (exists(envPath)) {
91
+ let secScore = 0;
92
+ const secIssues = [];
93
+ // (a) .env vs .env.example 동기화
94
+ try {
95
+ const d = envDiff(root);
96
+ if (d.inEnvOnly.length) {
97
+ secIssues.push(t(`.env→.env.example 누락 ${d.inEnvOnly.length}건`, `.env→.env.example missing ${d.inEnvOnly.length}`));
98
+ secScore += 15;
99
+ }
100
+ } catch {}
101
+ // (b) .gitignore 시크릿 패턴
102
+ try {
103
+ const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
104
+ const giLines = giText.split('\n').map(l => l.trim());
105
+ const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
106
+ const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
107
+ if (missing.length) {
108
+ secIssues.push(t(`.gitignore 시크릿 누락 ${missing.length}건`, `.gitignore missing secret patterns ${missing.length}`));
109
+ // 누락이 .env 자체면 최우선 위험 — 15점 가중
110
+ if (missing.includes('.env')) secScore += 30;
111
+ else secScore += Math.min(20, missing.length * 5);
112
+ }
113
+ } catch {}
114
+ if (secScore > 0) {
115
+ totalScore += secScore;
116
+ fired.push({ file: '.env / .gitignore', ageDays: null, threshold: 0, weight: secScore, label: t(`보안 위험 (1.9.78): ${secIssues.join(' · ')}`, `security risk: ${secIssues.join(' · ')}`) });
117
+ }
118
+ }
119
+ } catch {}
120
+ // 1.9.143: Feature Graph 미사용 신호 — 노드는 있는데 edges 비율 낮으면 인과관계 정리 미진
121
+ try {
122
+ const { nodes: fGraphNodes } = _readFeatureGraph(root);
123
+ if (fGraphNodes.length >= 3) {
124
+ const edgeCount = fGraphNodes.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
125
+ const linkedSet = new Set();
126
+ for (const n of fGraphNodes) {
127
+ for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedSet.add(n.id); linkedSet.add(x); }
128
+ }
129
+ const isolatedCount = Math.max(0, fGraphNodes.length - linkedSet.size);
130
+ const isolatedRatio = isolatedCount / fGraphNodes.length;
131
+ if (edgeCount === 0 || isolatedRatio >= 0.5) {
132
+ const fgScore = edgeCount === 0 ? 25 : 15;
133
+ totalScore += fgScore;
134
+ fired.push({ file: '.leerness/feature-graph.md', ageDays: null, threshold: 0, weight: fgScore, label: t(`Feature Graph 미정리 (1.9.143): ${fGraphNodes.length} 노드, edges=${edgeCount}, isolated=${isolatedCount}`, `Feature Graph unlinked: ${fGraphNodes.length} nodes, edges=${edgeCount}, isolated=${isolatedCount}`) });
135
+ }
136
+ }
137
+ } catch {}
138
+ // 신규 _apps/* 에서 task 0건도 신호로
139
+ const appsDir = path.join(root, '_apps');
140
+ let appsZeroTask = [];
141
+ if (exists(appsDir)) {
142
+ for (const d of fs.readdirSync(appsDir)) {
143
+ const sub = path.join(appsDir, d);
144
+ if (!exists(path.join(sub, '.leerness'))) continue;
145
+ const subRows = readProgressRows(sub);
146
+ if (!subRows.length) appsZeroTask.push(d);
147
+ }
148
+ if (appsZeroTask.length) {
149
+ const w = Math.min(50, appsZeroTask.length * 10);
150
+ totalScore += w;
151
+ fired.push({ file: `_apps/* (${appsZeroTask.length}개)`, ageDays: null, threshold: 0, weight: w, label: t(`task 0건 sub-app: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}`, `sub-app with 0 tasks: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}`) });
152
+ }
153
+ }
154
+ // 레벨 판정
155
+ let level = '🟢 healthy';
156
+ if (totalScore >= 100) level = '🔴 critical';
157
+ else if (totalScore >= 50) level = '🟡 warning';
158
+ else if (totalScore >= 20) level = '🟠 attention';
159
+
160
+ // 1.9.38 (D): drift critical 등급은 누적 카운트 (학습 신호)
161
+ try {
162
+ if (level === '🔴 critical') {
163
+ const stats = _readUsageStats(root);
164
+ stats.drift = stats.drift || {};
165
+ stats.drift.criticalSeen = (stats.drift.criticalSeen || 0) + 1;
166
+ const p = _usageStatsPath(root);
167
+ mkdirp(path.dirname(p));
168
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
169
+ }
170
+ } catch {}
171
+ // 1.9.39: --auto-fix — critical 시 session close 자동 실행
172
+ // 1.9.82: --auto-fix가 보안 신호도 자동 회복 (audit --fix 호출)
173
+ // 1.9.432 (10th 외부평가 Opus latent, UR-0131 잔여): depth 가드 — 재귀 호출(_noAutoFix)은 auto-fix 재진입 금지.
174
+ // 기존엔 autoFix=has('--auto-fix')가 전역 argv 재독→재귀도 auto-fix 분기 재진입, 종료는 'audit이 보안신호를 지운다'는 취약 불변식에 의존(미래 신호 타입이 비가역이면 무한재귀). 명시 1회 보장.
175
+ const autoFix = has('--auto-fix') && !opts._noAutoFix;
176
+ // 1.9.439 (10th 외부평가 Codex P1, UR-0135): --json 모드면 auto-fix 진행로그 억제(stdout 순수 JSON 보장).
177
+ // 재귀(_noAutoFix)는 auto-fix 블록을 건너뛰고 마지막 JSON(아래 has('--json') 블록)만 출력 → afLog 로 첫 패스 진행로그만 무음화.
178
+ const afLog = has('--json') ? () => {} : log;
179
+ const encodingFixErrors = Array.isArray(opts._encodingFixErrors) ? opts._encodingFixErrors.slice() : [];
180
+ const inheritedEncodingAutoFix = opts._encodingAutoFix && typeof opts._encodingAutoFix === 'object'
181
+ ? opts._encodingAutoFix : null;
182
+ const encodingAutoFix = inheritedEncodingAutoFix
183
+ ? { ...inheritedEncodingAutoFix, results: Array.isArray(inheritedEncodingAutoFix.results) ? inheritedEncodingAutoFix.results.slice() : [] }
184
+ : { requested: has('--auto-fix'), attempted: false, status: 'not-needed', scanned: 0, atRisk: 0, added: 0, skipped: 0, failed: 0, scanErrors: [], results: [] };
185
+ const inheritedAutoFixSummary = opts._autoFixSummary && typeof opts._autoFixSummary === 'object'
186
+ ? opts._autoFixSummary : null;
187
+ const autoFixSummary = inheritedAutoFixSummary
188
+ ? JSON.parse(JSON.stringify(inheritedAutoFixSummary))
189
+ : {
190
+ requested: has('--auto-fix'),
191
+ failed: 0,
192
+ stages: {
193
+ security: { attempted: false, status: 'not-needed', failed: 0, results: [] },
194
+ delivered: { attempted: false, status: 'not-needed', failed: 0, results: [] },
195
+ idempotency: { attempted: false, status: 'not-needed', failed: 0, results: [] },
196
+ releaseCleanup: { attempted: false, status: 'not-needed', failed: 0, results: [] },
197
+ session: { attempted: false, status: 'not-needed', failed: 0, results: [] },
198
+ },
199
+ };
200
+ const stage = name => {
201
+ autoFixSummary.stages[name] = autoFixSummary.stages[name]
202
+ || { attempted: false, status: 'not-needed', failed: 0, results: [] };
203
+ return autoFixSummary.stages[name];
204
+ };
205
+ const stageFail = (name, result) => {
206
+ const s = stage(name);
207
+ s.attempted = true;
208
+ s.status = 'failed';
209
+ s.failed = (s.failed || 0) + 1;
210
+ s.results = Array.isArray(s.results) ? s.results : [];
211
+ s.results.push(result);
212
+ };
213
+ const refreshAutoFixFailureCount = () => {
214
+ autoFixSummary.stages.encoding = encodingAutoFix;
215
+ autoFixSummary.failed = Object.values(autoFixSummary.stages)
216
+ .reduce((n, s) => n + (Number(s && s.failed) || 0), 0);
217
+ return autoFixSummary.failed;
218
+ };
219
+ let autoFixNeedsRecheck = false;
220
+ // 1.9.82: 보안 신호가 fired에 있으면 우선 audit --fix 호출
221
+ // 1.28.1 (Phase 10b): 보안 신호 판정을 *언어-안정* 한 file 필드로 — 1.27.2 에서 label 을 언어화하면서 한국어 라벨 regex 매칭이 --language en 에서 깨지던 버그 수정(보안 auto-fix 미발동 방지).
222
+ const hasSecurityFired = fired.some(f => f.file === '.env / .gitignore');
223
+ if (autoFix && hasSecurityFired) {
224
+ const securityStage = stage('security');
225
+ securityStage.attempted = true;
226
+ securityStage.status = 'running';
227
+ afLog('');
228
+ afLog(t(`🔒 --auto-fix 활성 (1.9.82) — 보안 신호 회복: audit --fix 자동 실행 중...`, `🔒 --auto-fix on — recovering security signal: running audit --fix...`));
229
+ try {
230
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'audit', root, '--fix'],
231
+ { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
232
+ if (r.status === 0) {
233
+ securityStage.status = 'applied';
234
+ securityStage.results.push({ action: 'audit-fix', status: 'applied', exitCode: 0 });
235
+ afLog(t(`✓ audit --fix 완료 — .gitignore + .env.example 동기화`, `✓ audit --fix done — .gitignore + .env.example synced`));
236
+ // T-0022: 여기서 즉시 return 하면 뒤의 encoding/dedup/cleanup
237
+ // auto-fix가 전부 조용히 건너뛰어진다. 모든 단계를 끝낸 뒤 한 번만 재검사.
238
+ autoFixNeedsRecheck = true;
239
+ } else {
240
+ stageFail('security', { action: 'audit-fix', status: 'failed', exitCode: r.status, stderr: String(r.stderr || '').trim().slice(0, 500) });
241
+ afLog(t(`⚠ audit --fix 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`, `⚠ audit --fix failed (exit ${r.status}) — run \`leerness audit --fix\` manually`));
242
+ }
243
+ } catch (e) {
244
+ stageFail('security', { action: 'audit-fix', status: 'failed', code: e.code || 'error', error: e.message });
245
+ afLog(t(`⚠ auto-fix 보안 회복 오류: ${e.message}`, `⚠ auto-fix security recovery error: ${e.message}`));
246
+ }
247
+ }
248
+ // 1.9.242: drift check --auto-fix 에 env encoding BOM 자동 추가 통합 (사용자 명시 UR-0014 2단계)
249
+ // 1.9.82 패턴 확장 — drift 회복 시 셸 스크립트 인코딩 위험도 자동 해결
250
+ if (autoFix) {
251
+ try {
252
+ encodingAutoFix.attempted = true;
253
+ encodingAutoFix.status = 'scanned';
254
+ const encScan = _scanShellScriptsEncoding(root);
255
+ encodingAutoFix.scanned = encScan.scanned || 0;
256
+ encodingAutoFix.atRisk = Array.isArray(encScan.atRisk) ? encScan.atRisk.length : 0;
257
+ encodingAutoFix.scanErrors = Array.isArray(encScan.scanErrors) ? encScan.scanErrors.slice() : [];
258
+ for (const scanError of encodingAutoFix.scanErrors) {
259
+ const failure = { file: scanError.file, action: 'failed-scan', code: scanError.code, error: scanError.error, operation: scanError.operation };
260
+ encodingFixErrors.push(failure);
261
+ encodingAutoFix.failed++;
262
+ encodingAutoFix.results.push(failure);
263
+ }
264
+ if (encScan.atRisk && encScan.atRisk.length > 0) {
265
+ afLog('');
266
+ afLog(t(`🌐 --auto-fix 활성 (1.9.242) — 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 BOM 자동 추가 중...`, `🌐 --auto-fix on — adding UTF-8 BOM to ${encScan.atRisk.length} at-risk shell script(s)...`));
267
+ let ok = 0;
268
+ let skipped = 0;
269
+ for (const r of encScan.atRisk) {
270
+ try {
271
+ const fullPath = path.join(root, r.file);
272
+ const applied = applyShellScriptUtf8Bom(fullPath, r.file);
273
+ encodingAutoFix.results.push(applied);
274
+ if (applied.action === 'utf8-bom-added') { ok++; encodingAutoFix.added++; }
275
+ else if (applied.action.startsWith('skipped-')) { skipped++; encodingAutoFix.skipped++; }
276
+ if (applied.backupFile) afLog(t(` ↳ 원본/동시 편집 복구본 보존: ${applied.backupFile}`, ` ↳ retained original/concurrent recovery copy: ${applied.backupFile}`));
277
+ } catch (e) {
278
+ const failure = { file: r.file, action: 'failed', code: e.code || 'error', error: e.message, ...(e.backupFile ? { backupFile: e.backupFile } : {}) };
279
+ if (e.recoveryArtifacts) failure.recoveryArtifacts = e.recoveryArtifacts;
280
+ encodingFixErrors.push(failure);
281
+ encodingAutoFix.failed++;
282
+ encodingAutoFix.results.push(failure);
283
+ afLog(t(`⚠ 인코딩 자동 수정 거부: ${r.file} — ${e.message}`, `⚠ encoding auto-fix refused: ${r.file} — ${e.message}`));
284
+ }
285
+ }
286
+ afLog(t(`✓ UTF-8 BOM 추가 ${ok}/${encScan.atRisk.length}건${skipped > 0 ? ` · 안전상 건너뜀 ${skipped}건` : ''} (1.9.242 UR-0014)`, `✓ added UTF-8 BOM ${ok}/${encScan.atRisk.length}${skipped > 0 ? ` · safely skipped ${skipped}` : ''}`));
287
+ }
288
+ encodingAutoFix.status = encodingAutoFix.failed > 0 ? 'failed'
289
+ : (encodingAutoFix.added > 0 ? 'applied' : (encodingAutoFix.skipped > 0 ? 'skipped' : 'not-needed'));
290
+ if (encodingAutoFix.added > 0) autoFixNeedsRecheck = true;
291
+ } catch (e) {
292
+ const failure = { file: null, action: 'failed', code: e.code || 'error', error: e.message };
293
+ encodingFixErrors.push(failure);
294
+ encodingAutoFix.failed++;
295
+ encodingAutoFix.status = 'failed';
296
+ encodingAutoFix.results.push(failure);
297
+ afLog(t(`⚠ env encoding auto-fix 오류 (1.9.242): ${e.message}`, `⚠ env encoding auto-fix error: ${e.message}`));
298
+ }
299
+ }
300
+ // 1.9.225: drift check --auto-fix 에 delivered 패턴 자동 적용 통합 (1.9.223/224 시스템 회수)
301
+ // 사용자 요청에 "구현 완료" 패턴이 누적되면 가짜 미답 신호가 drift score 를 가중시킬 수 있음 → 자동 정리.
302
+ // 1.9.82 audit --fix 패턴과 동일: --auto-fix 시 즉시 적용, 적용 후 재검사.
303
+ if (autoFix) {
304
+ const deliveredStage = stage('delivered');
305
+ deliveredStage.attempted = true;
306
+ deliveredStage.status = 'not-needed';
307
+ try {
308
+ const delivered = _detectDeliveredRequests(root);
309
+ deliveredStage.candidates = Array.isArray(delivered.candidates) ? delivered.candidates.length : 0;
310
+ if (delivered.candidates && delivered.candidates.length > 0) {
311
+ afLog('');
312
+ afLog(t(`📥 --auto-fix 활성 (1.9.225) — delivered 패턴 ${delivered.candidates.length}건 자동 완료 중...`, `📥 --auto-fix on — auto-completing ${delivered.candidates.length} delivered pattern(s)...`));
313
+ let ok = 0;
314
+ for (const c of delivered.candidates) {
315
+ try {
316
+ const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'drift-auto-fix-1.9.225' });
317
+ if (u) {
318
+ ok++;
319
+ deliveredStage.results.push({ id: c.id, action: 'completed', status: 'applied' });
320
+ } else {
321
+ stageFail('delivered', { id: c.id, action: 'completed', status: 'failed', error: 'request update returned no result' });
322
+ }
323
+ } catch (e) {
324
+ stageFail('delivered', { id: c.id, action: 'completed', status: 'failed', code: e.code || 'error', error: e.message });
325
+ }
326
+ }
327
+ if (deliveredStage.failed === 0) deliveredStage.status = 'applied';
328
+ deliveredStage.applied = ok;
329
+ if (ok > 0) autoFixNeedsRecheck = true;
330
+ afLog(t(`✓ delivered 자동 완료 ${ok}/${delivered.candidates.length}건`, `✓ delivered auto-completed ${ok}/${delivered.candidates.length}`));
331
+ }
332
+ } catch (e) {
333
+ stageFail('delivered', { action: 'detect-or-apply', status: 'failed', code: e.code || 'error', error: e.message });
334
+ afLog(t(`⚠ delivered auto-apply 오류 (1.9.225): ${e.message}`, `⚠ delivered auto-apply error: ${e.message}`));
335
+ }
336
+ }
337
+ // 1.9.293: drift check --auto-fix 에 idempotency task/user-request 중복 자동 정리 통합
338
+ // 누적 중복 task/요청이 idempotency 위반(medium)을 가중 → drift/handoff 노이즈. 안전: 완전중복 행 제거 + 동일텍스트 dropped 보존(id 유지).
339
+ if (autoFix) {
340
+ const idempotencyStage = stage('idempotency');
341
+ idempotencyStage.attempted = true;
342
+ idempotencyStage.status = 'not-needed';
343
+ try {
344
+ const idemFixes = _autoFixIdempotency(root);
345
+ idempotencyStage.results = Array.isArray(idemFixes) ? idemFixes.slice() : [];
346
+ const totalFixed = idemFixes.reduce((n, f) => n + (f.removedExact || 0) + (f.droppedSameText || 0) + (f.count || 0), 0);
347
+ // 1.36.131 (검수 P1): 실패 항목은 수치 합이 0 이라 종전엔 **조용히 지나갔다** — 중복이 남았는데 아무 말도 안 했다.
348
+ // 수치와 무관하게 실패는 말한다(이 명령은 회복 경로라 exit 를 바꾸지 않는다 — 대신 반드시 보인다).
349
+ const idemFailed = idemFixes.filter(f => f.action === 'error');
350
+ if (idemFailed.length) {
351
+ idempotencyStage.status = 'failed';
352
+ idempotencyStage.failed += idemFailed.length;
353
+ afLog('');
354
+ afLog(t(`❌ idempotency auto-fix 실패 ${idemFailed.length}건 — 중복이 남아 있다: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`,
355
+ `❌ idempotency auto-fix failed ${idemFailed.length} — duplicates remain: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`));
356
+ }
357
+ if (totalFixed > 0) {
358
+ if (idempotencyStage.failed === 0) idempotencyStage.status = 'applied';
359
+ idempotencyStage.applied = totalFixed;
360
+ autoFixNeedsRecheck = true;
361
+ afLog('');
362
+ afLog(t(`🔁 --auto-fix 활성 (1.9.293) — idempotency 중복 ${totalFixed}건 자동 정리 (task/user-request dedup)`, `🔁 --auto-fix on — deduped ${totalFixed} idempotency duplicate(s) (task/user-request)`));
363
+ }
364
+ } catch (e) {
365
+ stageFail('idempotency', { action: 'deduplicate', status: 'failed', code: e.code || 'error', error: e.message });
366
+ afLog(t(`⚠ idempotency auto-fix 오류 (1.9.293): ${e.message}`, `⚠ idempotency auto-fix error: ${e.message}`));
367
+ }
368
+ }
369
+ // 1.9.236: drift check --auto-fix 에 release cleanup 통합 (1.9.235 회수)
370
+ // 누적된 50개+ release/* branches → abnormal-shutdown release-branch-pending 신호 가중
371
+ // 안전: keep 10 (최근 10개 유지), merged 만 삭제 (1.9.235 안전 가드)
372
+ // 임계: 50개 초과 시만 자동 정리 (소량 누적은 정상 운영)
373
+ if (autoFix) {
374
+ const releaseStage = stage('releaseCleanup');
375
+ releaseStage.attempted = true;
376
+ releaseStage.status = 'not-needed';
377
+ try {
378
+ // A repository with no commits has no `main` object yet. First count
379
+ // release refs using for-each-ref (valid in unborn repositories); only
380
+ // resolve merge ancestry when cleanup could actually cross the 50-ref
381
+ // threshold.
382
+ const refsR = gitSpawn(['for-each-ref', '--format=%(refname:short)', 'refs/heads/release/'], { cwd: root, encoding: 'utf8' });
383
+ if (refsR.status === 0) {
384
+ const releaseRefs = (refsR.stdout || '').split('\n')
385
+ .map(l => l.trim())
386
+ .filter(l => /^release\/\d+\.\d+\.\d+$/.test(l));
387
+ releaseStage.candidates = releaseRefs.length;
388
+ if (releaseRefs.length <= 50) {
389
+ releaseStage.results.push({ action: 'list-release-refs', status: 'not-needed', count: releaseRefs.length, threshold: 50 });
390
+ } else {
391
+ const branchR = gitSpawn(['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
392
+ if (branchR.status !== 0) {
393
+ stageFail('releaseCleanup', {
394
+ action: 'list-merged', status: 'failed', exitCode: branchR.status,
395
+ ...(branchR.error ? { code: branchR.error.code || 'error', error: branchR.error.message || String(branchR.error) } : {}),
396
+ stderr: String(branchR.stderr || '').trim().slice(0, 500),
397
+ });
398
+ } else {
399
+ const merged = (branchR.stdout || '').split('\n')
400
+ .map(l => l.replace(/^\*?\s+/, '').trim())
401
+ .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
402
+ if (merged.length > 50) {
403
+ afLog('');
404
+ afLog(t(`🗑 --auto-fix 활성 (1.9.236) — release/* merged ${merged.length}개 (50+) 자동 정리 (keep 10)...`, `🗑 --auto-fix on — cleaning up ${merged.length} merged release/* branches (50+, keep 10)...`));
405
+ // 정렬 (semver desc)
406
+ merged.sort((a, b) => {
407
+ const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
408
+ const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
409
+ for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
410
+ return 0;
411
+ });
412
+ const currentBranchR = gitSpawn(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
413
+ if (currentBranchR.status !== 0) {
414
+ stageFail('releaseCleanup', { action: 'current-branch', status: 'failed', exitCode: currentBranchR.status, stderr: String(currentBranchR.stderr || '').trim().slice(0, 500) });
415
+ }
416
+ const currentBranch = (currentBranchR.stdout || '').trim();
417
+ const toDelete = currentBranchR.status === 0 ? merged.slice(10).filter(b => b !== currentBranch) : [];
418
+ let ok = 0;
419
+ for (const b of toDelete) {
420
+ const r = gitSpawn(['branch', '-d', b], { cwd: root, encoding: 'utf8' });
421
+ if (r.status === 0) {
422
+ ok++;
423
+ releaseStage.results.push({ branch: b, action: 'delete', status: 'applied' });
424
+ } else {
425
+ stageFail('releaseCleanup', { branch: b, action: 'delete', status: 'failed', exitCode: r.status, stderr: String(r.stderr || '').trim().slice(0, 500) });
426
+ }
427
+ }
428
+ releaseStage.applied = ok;
429
+ if (releaseStage.failed === 0) releaseStage.status = 'applied';
430
+ if (ok > 0) autoFixNeedsRecheck = true;
431
+ afLog(t(`✓ release cleanup 자동 완료 ${ok}/${toDelete.length}건 (keep 10)`, `✓ release cleanup done ${ok}/${toDelete.length} (keep 10)`));
432
+ }
433
+ }
434
+ }
435
+ } else {
436
+ const repoR = gitSpawn(['rev-parse', '--is-inside-work-tree'], { cwd: root, encoding: 'utf8' });
437
+ if (refsR.error || repoR.error || repoR.status === 0) {
438
+ const spawnError = refsR.error || repoR.error;
439
+ stageFail('releaseCleanup', {
440
+ action: 'list-release-refs', status: 'failed', exitCode: refsR.status,
441
+ ...(spawnError ? { code: spawnError.code || 'error', error: spawnError.message || String(spawnError) } : {}),
442
+ stderr: String(refsR.stderr || '').trim().slice(0, 500),
443
+ });
444
+ } else {
445
+ releaseStage.status = 'not-applicable';
446
+ releaseStage.results.push({ action: 'list-release-refs', status: 'skipped', reason: 'not-a-git-worktree' });
447
+ }
448
+ }
449
+ } catch (e) {
450
+ stageFail('releaseCleanup', { action: 'cleanup', status: 'failed', code: e.code || 'error', error: e.message });
451
+ afLog(t(`⚠ release cleanup auto-fix 오류 (1.9.236): ${e.message}`, `⚠ release cleanup auto-fix error: ${e.message}`));
452
+ }
453
+ }
454
+ if (autoFix && level === '🔴 critical') {
455
+ const sessionStage = stage('session');
456
+ sessionStage.attempted = true;
457
+ sessionStage.status = 'running';
458
+ afLog('');
459
+ afLog(t(`🔧 --auto-fix 활성 — session close 자동 실행 중...`, `🔧 --auto-fix on — running session close...`));
460
+ try {
461
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'session', 'close', root], { encoding: 'utf8', timeout: 60000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
462
+ if (r.status === 0) {
463
+ sessionStage.status = 'applied';
464
+ sessionStage.results.push({ action: 'session-close', status: 'applied', exitCode: 0 });
465
+ afLog(t(`✓ session close 자동 완료`, `✓ session close done`));
466
+ // autoResolved 카운트
467
+ const stats = _readUsageStats(root);
468
+ stats.drift = stats.drift || {};
469
+ stats.drift.autoResolved = (stats.drift.autoResolved || 0) + 1;
470
+ const p = _usageStatsPath(root);
471
+ mkdirp(path.dirname(p));
472
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
473
+ autoFixNeedsRecheck = true;
474
+ } else {
475
+ stageFail('session', { action: 'session-close', status: 'failed', exitCode: r.status, stderr: String(r.stderr || '').trim().slice(0, 500) });
476
+ afLog(t(`⚠ session close 실패 (exit ${r.status}) — 수동 실행 필요`, `⚠ session close failed (exit ${r.status}) — run manually`));
477
+ }
478
+ } catch (e) {
479
+ stageFail('session', { action: 'session-close', status: 'failed', code: e.code || 'error', error: e.message });
480
+ afLog(t(`⚠ auto-fix 오류: ${e.message}`, `⚠ auto-fix error: ${e.message}`));
481
+ }
482
+ }
483
+ if (autoFixNeedsRecheck) {
484
+ afLog('');
485
+ afLog(t(`모든 auto-fix 단계 완료 — 재검사 중...`, `all auto-fix stages complete — re-checking...`));
486
+ refreshAutoFixFailureCount();
487
+ return driftCheckCmd(root, { ...opts, _noAutoFix: true, _encodingFixErrors: encodingFixErrors, _encodingAutoFix: encodingAutoFix, _autoFixSummary: autoFixSummary }, deps);
488
+ }
489
+ const autoFixFailures = refreshAutoFixFailureCount();
490
+ if (has('--json')) {
491
+ // 1.36.66 (8차 헌트 F4): --json 도 사람용과 exit 동치 — critical 은 exit 1 (조기 return 전에 설정)
492
+ if (encodingFixErrors.length || autoFixFailures > 0) process.exitCode = 1;
493
+ if (level === '🔴 critical') process.exitCode = 1;
494
+ log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask, encodingFixErrors, encodingAutoFix: encodingAutoFix.requested ? encodingAutoFix : null, autoFixSummary: autoFixSummary.requested ? autoFixSummary : null }, null, 2));
495
+ return;
496
+ }
497
+ if (encodingFixErrors.length || autoFixFailures > 0) process.exitCode = 1;
498
+ log(`# leerness drift check (1.9.37)`);
499
+ log(t(`경로: ${root}`, `path: ${root}`));
500
+ log('');
501
+ log(t(`상태: ${level} · 점수 ${totalScore}/200`, `status: ${level} · score ${totalScore}/200`));
502
+ log('');
503
+ log(t(`| 신호 | age | 임계 | 가중치 | 발화 |`, `| signal | age | threshold | weight | fired |`));
504
+ log(`|---|---:|---:|---:|---|`);
505
+ for (const s of signals) {
506
+ const fire = s.ageDays > s.threshold ? '🔥' : '✓';
507
+ const age = s.ageDays === null ? '-' : `${s.ageDays.toFixed(1)}d`;
508
+ log(`| ${s.label} | ${age} | ${s.threshold}d | ${s.weight} | ${fire} |`);
509
+ }
510
+ if (appsZeroTask.length) {
511
+ log('');
512
+ log(t(`task 0건 sub-app (${appsZeroTask.length}개): ${appsZeroTask.join(', ')}`, `sub-apps with 0 tasks (${appsZeroTask.length}): ${appsZeroTask.join(', ')}`));
513
+ }
514
+ if (totalScore >= 50) {
515
+ log('');
516
+ log(t(`💡 권장 조치:`, `💡 recommended actions:`));
517
+ log(t(` - 즉시: leerness session close . (handoff/current-state 갱신)`, ` - now: leerness session close . (refresh handoff/current-state)`));
518
+ log(t(` - 또는: leerness audit . --fix (자동 갱신 가능 항목 적용)`, ` - or: leerness audit . --fix (apply auto-fixable items)`));
519
+ log(t(` - sub-app에 task 등록: cd _apps/X && leerness task add "..."`, ` - add tasks to a sub-app: cd _apps/X && leerness task add "..."`));
520
+ log(t(` - 이 검사 끄기: --no-drift-check 또는 LEERNESS_NO_DRIFT_CHECK=1`, ` - disable this check: --no-drift-check or LEERNESS_NO_DRIFT_CHECK=1`));
521
+ }
522
+ if (level === '🔴 critical') process.exitCode = 1;
523
+ }
524
+
525
+ module.exports = { driftCheckCmd };