leerness 1.9.421 → 1.9.423

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 ADDED
@@ -0,0 +1,334 @@
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
+ function driftCheckCmd(root, opts = {}, deps = {}) {
11
+ const { VERSION, has, arg, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath, envDiff, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency } = deps;
12
+ root = absRoot(root || process.cwd());
13
+ const now = Date.now();
14
+ const _ageDays = (p) => {
15
+ if (!exists(p)) return null;
16
+ return (now - fs.statSync(p).mtimeMs) / 86400000;
17
+ };
18
+ // 각 메타파일의 마지막 갱신
19
+ const signals = [];
20
+ // 1. session-handoff.md - "Last generated" 라인 우선, 없으면 mtime
21
+ const shPath = handoffPath(root);
22
+ if (exists(shPath)) {
23
+ const txt = read(shPath);
24
+ // 1.9.316 (drift 마커 버그): 최신(마지막) 'Last generated' 사용 — 구 블록 중복 시 첫(구) 매치를 읽던 오발화 방어.
25
+ const allGen = [...txt.matchAll(/Last generated:\s*([\d\-T:.Z]+)/g)];
26
+ const m = allGen.length ? allGen[allGen.length - 1] : null;
27
+ let ageDays;
28
+ if (m) {
29
+ ageDays = (now - new Date(m[1]).getTime()) / 86400000;
30
+ } else {
31
+ ageDays = _ageDays(shPath);
32
+ }
33
+ signals.push({ file: 'session-handoff.md', ageDays, threshold: 1, weight: 30, label: 'session close 누락' });
34
+ }
35
+ // 2. current-state.md - "Updated: YYYY-MM-DD" 라인
36
+ const csPath = currentStatePath(root);
37
+ if (exists(csPath)) {
38
+ const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
39
+ const ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
40
+ signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: 'current-state 갱신 없음' });
41
+ }
42
+ // 3. progress-tracker.md 마지막 row의 updated 컬럼
43
+ const rows = readProgressRows(root);
44
+ if (rows.length) {
45
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]);
46
+ if (dates.length) {
47
+ dates.sort();
48
+ const latest = dates[dates.length - 1];
49
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
50
+ signals.push({ file: 'progress-tracker.md', ageDays, threshold: 1, weight: 30, label: 'task update 없음' });
51
+ }
52
+ } else {
53
+ signals.push({ file: 'progress-tracker.md', ageDays: 999, threshold: 1, weight: 25, label: 'progress-tracker 비어있음' });
54
+ }
55
+ // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
56
+ const tlPath = taskLogPath(root);
57
+ if (exists(tlPath)) {
58
+ const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]);
59
+ if (dates.length) {
60
+ dates.sort();
61
+ const latest = dates[dates.length - 1];
62
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
63
+ signals.push({ file: 'task-log.md', ageDays, threshold: 2, weight: 20, label: 'task-log 갱신 없음' });
64
+ }
65
+ }
66
+ // 점수 계산
67
+ let totalScore = 0;
68
+ const fired = [];
69
+ for (const s of signals) {
70
+ if (s.ageDays > s.threshold) {
71
+ totalScore += s.weight;
72
+ fired.push(s);
73
+ }
74
+ }
75
+ // 1.9.78: 보안 신호 (env / .gitignore 누락) — 5번째 신호
76
+ try {
77
+ const envPath = path.join(root, '.env');
78
+ if (exists(envPath)) {
79
+ let secScore = 0;
80
+ const secIssues = [];
81
+ // (a) .env vs .env.example 동기화
82
+ try {
83
+ const d = envDiff(root);
84
+ if (d.inEnvOnly.length) {
85
+ secIssues.push(`.env→.env.example 누락 ${d.inEnvOnly.length}건`);
86
+ secScore += 15;
87
+ }
88
+ } catch {}
89
+ // (b) .gitignore 시크릿 패턴
90
+ try {
91
+ const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
92
+ const giLines = giText.split('\n').map(l => l.trim());
93
+ const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
94
+ const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
95
+ if (missing.length) {
96
+ secIssues.push(`.gitignore 시크릿 누락 ${missing.length}건`);
97
+ // 누락이 .env 자체면 최우선 위험 — 15점 가중
98
+ if (missing.includes('.env')) secScore += 30;
99
+ else secScore += Math.min(20, missing.length * 5);
100
+ }
101
+ } catch {}
102
+ if (secScore > 0) {
103
+ totalScore += secScore;
104
+ fired.push({ file: '.env / .gitignore', ageDays: null, threshold: 0, weight: secScore, label: `보안 위험 (1.9.78): ${secIssues.join(' · ')}` });
105
+ }
106
+ }
107
+ } catch {}
108
+ // 1.9.143: Feature Graph 미사용 신호 — 노드는 있는데 edges 비율 낮으면 인과관계 정리 미진
109
+ try {
110
+ const { nodes: fGraphNodes } = _readFeatureGraph(root);
111
+ if (fGraphNodes.length >= 3) {
112
+ const edgeCount = fGraphNodes.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
113
+ const linkedSet = new Set();
114
+ for (const n of fGraphNodes) {
115
+ for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedSet.add(n.id); linkedSet.add(x); }
116
+ }
117
+ const isolatedCount = Math.max(0, fGraphNodes.length - linkedSet.size);
118
+ const isolatedRatio = isolatedCount / fGraphNodes.length;
119
+ if (edgeCount === 0 || isolatedRatio >= 0.5) {
120
+ const fgScore = edgeCount === 0 ? 25 : 15;
121
+ totalScore += fgScore;
122
+ fired.push({ file: '.harness/feature-graph.md', ageDays: null, threshold: 0, weight: fgScore, label: `Feature Graph 미정리 (1.9.143): ${fGraphNodes.length} 노드, edges=${edgeCount}, isolated=${isolatedCount}` });
123
+ }
124
+ }
125
+ } catch {}
126
+ // 신규 _apps/* 에서 task 0건도 신호로
127
+ const appsDir = path.join(root, '_apps');
128
+ let appsZeroTask = [];
129
+ if (exists(appsDir)) {
130
+ for (const d of fs.readdirSync(appsDir)) {
131
+ const sub = path.join(appsDir, d);
132
+ if (!exists(path.join(sub, '.harness'))) continue;
133
+ const subRows = readProgressRows(sub);
134
+ if (!subRows.length) appsZeroTask.push(d);
135
+ }
136
+ if (appsZeroTask.length) {
137
+ const w = Math.min(50, appsZeroTask.length * 10);
138
+ totalScore += w;
139
+ fired.push({ file: `_apps/* (${appsZeroTask.length}개)`, ageDays: null, threshold: 0, weight: w, label: `task 0건 sub-app: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}` });
140
+ }
141
+ }
142
+ // 레벨 판정
143
+ let level = '🟢 healthy';
144
+ if (totalScore >= 100) level = '🔴 critical';
145
+ else if (totalScore >= 50) level = '🟡 warning';
146
+ else if (totalScore >= 20) level = '🟠 attention';
147
+
148
+ // 1.9.38 (D): drift critical 등급은 누적 카운트 (학습 신호)
149
+ try {
150
+ if (level === '🔴 critical') {
151
+ const stats = _readUsageStats(root);
152
+ stats.drift = stats.drift || {};
153
+ stats.drift.criticalSeen = (stats.drift.criticalSeen || 0) + 1;
154
+ const p = _usageStatsPath(root);
155
+ mkdirp(path.dirname(p));
156
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
157
+ }
158
+ } catch {}
159
+ // 1.9.39: --auto-fix — critical 시 session close 자동 실행
160
+ // 1.9.82: --auto-fix가 보안 신호도 자동 회복 (audit --fix 호출)
161
+ const autoFix = has('--auto-fix');
162
+ // 1.9.82: 보안 신호가 fired에 있으면 우선 audit --fix 호출
163
+ const hasSecurityFired = fired.some(f => /보안 위험 \(1\.9\.78\)/.test(f.label));
164
+ if (autoFix && hasSecurityFired) {
165
+ log('');
166
+ log(`🔒 --auto-fix 활성 (1.9.82) — 보안 신호 회복: audit --fix 자동 실행 중...`);
167
+ try {
168
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'audit', root, '--fix'],
169
+ { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
170
+ if (r.status === 0) {
171
+ log(`✓ audit --fix 완료 — .gitignore + .env.example 동기화`);
172
+ // 재검사 (보안 신호 회복 확인)
173
+ log('');
174
+ log(`재검사 중...`);
175
+ return driftCheckCmd(root, opts, deps); // 재귀 1회 (auto-fix 없이)
176
+ } else {
177
+ log(`⚠ audit --fix 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`);
178
+ }
179
+ } catch (e) {
180
+ log(`⚠ auto-fix 보안 회복 오류: ${e.message}`);
181
+ }
182
+ }
183
+ // 1.9.242: drift check --auto-fix 에 env encoding BOM 자동 추가 통합 (사용자 명시 UR-0014 2단계)
184
+ // 1.9.82 패턴 확장 — drift 회복 시 셸 스크립트 인코딩 위험도 자동 해결
185
+ if (autoFix) {
186
+ try {
187
+ const encScan = _scanShellScriptsEncoding(root);
188
+ if (encScan.atRisk && encScan.atRisk.length > 0) {
189
+ log('');
190
+ log(`🌐 --auto-fix 활성 (1.9.242) — 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 BOM 자동 추가 중...`);
191
+ let ok = 0;
192
+ for (const r of encScan.atRisk) {
193
+ try {
194
+ const fullPath = path.join(root, r.file);
195
+ const orig = fs.readFileSync(fullPath);
196
+ const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
197
+ const fixed = Buffer.concat([bom, orig]);
198
+ fs.writeFileSync(fullPath, fixed);
199
+ ok++;
200
+ } catch {}
201
+ }
202
+ log(`✓ UTF-8 BOM 추가 ${ok}/${encScan.atRisk.length}건 (1.9.242 UR-0014)`);
203
+ }
204
+ } catch (e) {
205
+ log(`⚠ env encoding auto-fix 오류 (1.9.242): ${e.message}`);
206
+ }
207
+ }
208
+ // 1.9.225: drift check --auto-fix 에 delivered 패턴 자동 적용 통합 (1.9.223/224 시스템 회수)
209
+ // 사용자 요청에 "구현 완료" 패턴이 누적되면 가짜 미답 신호가 drift score 를 가중시킬 수 있음 → 자동 정리.
210
+ // 1.9.82 audit --fix 패턴과 동일: --auto-fix 시 즉시 적용, 적용 후 재검사.
211
+ if (autoFix) {
212
+ try {
213
+ const delivered = _detectDeliveredRequests(root);
214
+ if (delivered.candidates && delivered.candidates.length > 0) {
215
+ log('');
216
+ log(`📥 --auto-fix 활성 (1.9.225) — delivered 패턴 ${delivered.candidates.length}건 자동 완료 중...`);
217
+ let ok = 0;
218
+ for (const c of delivered.candidates) {
219
+ const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'drift-auto-fix-1.9.225' });
220
+ if (u) ok++;
221
+ }
222
+ log(`✓ delivered 자동 완료 ${ok}/${delivered.candidates.length}건`);
223
+ }
224
+ } catch (e) {
225
+ log(`⚠ delivered auto-apply 오류 (1.9.225): ${e.message}`);
226
+ }
227
+ }
228
+ // 1.9.293: drift check --auto-fix 에 idempotency task/user-request 중복 자동 정리 통합
229
+ // 누적 중복 task/요청이 idempotency 위반(medium)을 가중 → drift/handoff 노이즈. 안전: 완전중복 행 제거 + 동일텍스트 dropped 보존(id 유지).
230
+ if (autoFix) {
231
+ try {
232
+ const idemFixes = _autoFixIdempotency(root);
233
+ const totalFixed = idemFixes.reduce((n, f) => n + (f.removedExact || 0) + (f.droppedSameText || 0) + (f.count || 0), 0);
234
+ if (totalFixed > 0) {
235
+ log('');
236
+ log(`🔁 --auto-fix 활성 (1.9.293) — idempotency 중복 ${totalFixed}건 자동 정리 (task/user-request dedup)`);
237
+ }
238
+ } catch (e) {
239
+ log(`⚠ idempotency auto-fix 오류 (1.9.293): ${e.message}`);
240
+ }
241
+ }
242
+ // 1.9.236: drift check --auto-fix 에 release cleanup 통합 (1.9.235 회수)
243
+ // 누적된 50개+ release/* branches → abnormal-shutdown release-branch-pending 신호 가중
244
+ // 안전: keep 10 (최근 10개 유지), merged 만 삭제 (1.9.235 안전 가드)
245
+ // 임계: 50개 초과 시만 자동 정리 (소량 누적은 정상 운영)
246
+ if (autoFix) {
247
+ try {
248
+ const branchR = cp.spawnSync('git', ['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
249
+ if (branchR.status === 0) {
250
+ const merged = (branchR.stdout || '').split('\n')
251
+ .map(l => l.replace(/^\*?\s+/, '').trim())
252
+ .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
253
+ if (merged.length > 50) {
254
+ log('');
255
+ log(`🗑 --auto-fix 활성 (1.9.236) — release/* merged ${merged.length}개 (50+) 자동 정리 (keep 10)...`);
256
+ // 정렬 (semver desc)
257
+ merged.sort((a, b) => {
258
+ const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
259
+ const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
260
+ for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
261
+ return 0;
262
+ });
263
+ const currentBranchR = cp.spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
264
+ const currentBranch = (currentBranchR.stdout || '').trim();
265
+ const toDelete = merged.slice(10).filter(b => b !== currentBranch);
266
+ let ok = 0;
267
+ for (const b of toDelete) {
268
+ const r = cp.spawnSync('git', ['branch', '-d', b], { cwd: root, encoding: 'utf8' });
269
+ if (r.status === 0) ok++;
270
+ }
271
+ log(`✓ release cleanup 자동 완료 ${ok}/${toDelete.length}건 (keep 10)`);
272
+ }
273
+ }
274
+ } catch (e) {
275
+ log(`⚠ release cleanup auto-fix 오류 (1.9.236): ${e.message}`);
276
+ }
277
+ }
278
+ if (autoFix && level === '🔴 critical' && !hasSecurityFired) {
279
+ log('');
280
+ log(`🔧 --auto-fix 활성 — session close 자동 실행 중...`);
281
+ try {
282
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'session', 'close', root], { encoding: 'utf8', timeout: 60000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
283
+ if (r.status === 0) {
284
+ log(`✓ session close 자동 완료`);
285
+ // autoResolved 카운트
286
+ const stats = _readUsageStats(root);
287
+ stats.drift = stats.drift || {};
288
+ stats.drift.autoResolved = (stats.drift.autoResolved || 0) + 1;
289
+ const p = _usageStatsPath(root);
290
+ mkdirp(path.dirname(p));
291
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
292
+ // 재검사
293
+ log('');
294
+ log(`재검사 중...`);
295
+ return driftCheckCmd(root, opts, deps); // 재귀 1회 (auto-fix 없이)
296
+ } else {
297
+ log(`⚠ session close 실패 (exit ${r.status}) — 수동 실행 필요`);
298
+ }
299
+ } catch (e) {
300
+ log(`⚠ auto-fix 오류: ${e.message}`);
301
+ }
302
+ }
303
+ if (has('--json')) {
304
+ log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask }, null, 2));
305
+ return;
306
+ }
307
+ log(`# leerness drift check (1.9.37)`);
308
+ log(`경로: ${root}`);
309
+ log('');
310
+ log(`상태: ${level} · 점수 ${totalScore}/200`);
311
+ log('');
312
+ log(`| 신호 | age | 임계 | 가중치 | 발화 |`);
313
+ log(`|---|---:|---:|---:|---|`);
314
+ for (const s of signals) {
315
+ const fire = s.ageDays > s.threshold ? '🔥' : '✓';
316
+ const age = s.ageDays === null ? '-' : `${s.ageDays.toFixed(1)}d`;
317
+ log(`| ${s.label} | ${age} | ${s.threshold}d | ${s.weight} | ${fire} |`);
318
+ }
319
+ if (appsZeroTask.length) {
320
+ log('');
321
+ log(`task 0건 sub-app (${appsZeroTask.length}개): ${appsZeroTask.join(', ')}`);
322
+ }
323
+ if (totalScore >= 50) {
324
+ log('');
325
+ log(`💡 권장 조치:`);
326
+ log(` - 즉시: leerness session close . (handoff/current-state 갱신)`);
327
+ log(` - 또는: leerness audit . --fix (자동 갱신 가능 항목 적용)`);
328
+ log(` - sub-app에 task 등록: cd _apps/X && leerness task add "..."`);
329
+ log(` - 이 검사 끄기: --no-drift-check 또는 LEERNESS_NO_DRIFT_CHECK=1`);
330
+ }
331
+ if (level === '🔴 critical') process.exitCode = 1;
332
+ }
333
+
334
+ module.exports = { driftCheckCmd };