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/health.js CHANGED
@@ -189,9 +189,11 @@ function healthCmd(root, deps = {}) {
189
189
  isKoreanWindows: runtimeEnv.locale.isKoreanWindows || false,
190
190
  codepage: runtimeEnv.locale.codepage || null,
191
191
  nodeVersion: runtimeEnv.node.version,
192
- shellScriptsScanned: encScan.scanned,
193
- encodingRiskCount: encScan.atRisk.length,
194
- encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
192
+ shellScriptsScanned: encScan.scanned,
193
+ encodingRiskCount: encScan.atRisk.length,
194
+ encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
195
+ encodingScanErrorCount: (encScan.scanErrors || []).length,
196
+ encodingScanErrors: (encScan.scanErrors || []).slice(0, 5),
195
197
  // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
196
198
  terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
197
199
  autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
@@ -301,7 +303,8 @@ function healthCmd(root, deps = {}) {
301
303
  if (out.checks.security?.hasDotEnv && out.checks.security?.envInGitignore === false) issues.push(t('🚨 .env가 .gitignore에 누락 (보안 CRITICAL)', '🚨 .env missing from .gitignore (security CRITICAL)'));
302
304
  if (out.checks.security?.envExampleMissing?.length) issues.push(t(`.env→.env.example 누락 ${out.checks.security.envExampleMissing.length}건`, `.env→.env.example missing ${out.checks.security.envExampleMissing.length}`));
303
305
  if (out.checks.security?.gitignoreMissingSecrets?.length) issues.push(t(`.gitignore 시크릿 누락 ${out.checks.security.gitignoreMissingSecrets.length}건`, `.gitignore missing secret patterns ${out.checks.security.gitignoreMissingSecrets.length}`));
304
- if (out.checks.stateIntegrity?.corruptedCount > 0) issues.push(t(`🗄 상태파일 JSON 손상 ${out.checks.stateIntegrity.corruptedCount}건 (${out.checks.stateIntegrity.corrupted.map(c => c.file).join(', ')}) — 수동 복구/leerness init 필요`, `🗄 ${out.checks.stateIntegrity.corruptedCount} corrupted state JSON file(s) (${out.checks.stateIntegrity.corrupted.map(c => c.file).join(', ')}) — repair or leerness init`)); // 1.36.1 (클린룸 리뷰 FN)
306
+ if (out.checks.stateIntegrity?.corruptedCount > 0) issues.push(t(`🗄 상태파일 JSON 손상 ${out.checks.stateIntegrity.corruptedCount}건 (${out.checks.stateIntegrity.corrupted.map(c => c.file).join(', ')}) — 수동 복구/leerness init 필요`, `🗄 ${out.checks.stateIntegrity.corruptedCount} corrupted state JSON file(s) (${out.checks.stateIntegrity.corrupted.map(c => c.file).join(', ')}) — repair or leerness init`)); // 1.36.1 (클린룸 리뷰 FN)
307
+ if (out.envInfo?.encodingScanErrorCount > 0) issues.push(t(`인코딩 스캔 읽기 실패 ${out.envInfo.encodingScanErrorCount}건 — 결과 불완전`, `${out.envInfo.encodingScanErrorCount} encoding scan read error(s) — result incomplete`));
305
308
  out.issues = issues;
306
309
  out.healthy = issues.length === 0;
307
310
 
package/lib/io.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
+ const cp = require('child_process');
9
10
 
10
11
  // 1.10.2 (UR-0146): quiet 모드 — 사람용 출력(log/ok/warn) 억제. init --json 등에서 큰 핸들러의 다수 log 를 비침투적으로 묵음 → 순수 JSON 1개만 출력.
11
12
  // fail/failJson(오류)은 묵음 대상 아님(에러는 항상 노출). setQuiet 로 토글, 호출부 finally 에서 반드시 복구.
@@ -222,6 +223,246 @@ function writeUtf8(p, s) {
222
223
  throw e;
223
224
  }
224
225
  }
226
+ function _concurrentModification(p, backupFile) {
227
+ const recovery = backupFile ? ` (복구 백업: ${backupFile})` : '';
228
+ const e = new Error(`검사 후 파일이 변경되어 덮어쓰기를 거부했습니다: ${p}${recovery}`);
229
+ e.code = 'E_CONCURRENT_MODIFICATION';
230
+ e.file = p;
231
+ if (backupFile) e.backupFile = backupFile;
232
+ return e;
233
+ }
234
+ function _cleanupWriteArtifact(p) {
235
+ let lastError = null;
236
+ for (let i = 0; i < 5; i++) {
237
+ try { if (fs.existsSync(p)) fs.unlinkSync(p); return null; } catch (e) { lastError = e; _sleepMsSync(20 + i * 20); }
238
+ }
239
+ return lastError || new Error(`임시 쓰기 아티팩트를 정리하지 못했습니다: ${p}`);
240
+ }
241
+ function _cleanupWriteScratch(p) {
242
+ if (!p) return null;
243
+ let lastError = null;
244
+ for (let i = 0; i < 5; i++) {
245
+ try { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true }); return null; } catch (e) { lastError = e; _sleepMsSync(20 + i * 20); }
246
+ }
247
+ return lastError || new Error(`임시 쓰기 디렉터리를 정리하지 못했습니다: ${p}`);
248
+ }
249
+ function _mutationIdentity(stat) {
250
+ return {
251
+ dev: stat.dev,
252
+ ino: stat.ino,
253
+ nlink: stat.nlink,
254
+ mode: stat.mode,
255
+ };
256
+ }
257
+ function _sameMutationIdentity(a, b) {
258
+ return !!a && !!b && a.dev === b.dev && a.ino === b.ino;
259
+ }
260
+ function _readMutationIdentity(p) {
261
+ const link = fs.lstatSync(p);
262
+ if (link.isSymbolicLink()) {
263
+ const e = new Error(`심볼릭 링크 대상은 자동 교체하지 않습니다: ${p}`);
264
+ e.code = 'E_UNSAFE_LINK';
265
+ throw e;
266
+ }
267
+ if (!link.isFile()) {
268
+ const e = new Error(`일반 파일만 자동 교체할 수 있습니다: ${p}`);
269
+ e.code = 'E_UNSAFE_FILE_TYPE';
270
+ throw e;
271
+ }
272
+ return _mutationIdentity(link);
273
+ }
274
+ function _identityChanged(p, expected) {
275
+ const current = _readMutationIdentity(p);
276
+ if (!_sameMutationIdentity(current, expected) || current.nlink !== 1 || (current.mode & 0o222) === 0) {
277
+ const e = _concurrentModification(p);
278
+ e.expectedIdentity = expected;
279
+ e.actualIdentity = current;
280
+ throw e;
281
+ }
282
+ return current;
283
+ }
284
+ function _mutationRecoveryParent(p, expectedIdentity) {
285
+ let dir = path.dirname(path.resolve(p));
286
+ const volumeRoot = path.parse(dir).root;
287
+ while (true) {
288
+ const workspace = path.join(dir, '.leerness');
289
+ try {
290
+ if (fs.statSync(workspace).isDirectory() && fs.statSync(dir).dev === expectedIdentity.dev) {
291
+ const recovery = path.join(workspace, 'archive', 'mutation-recovery');
292
+ mkdirp(recovery);
293
+ return recovery;
294
+ }
295
+ } catch {}
296
+ if (dir === volumeRoot) break;
297
+ const parent = path.dirname(dir);
298
+ if (parent === dir) break;
299
+ dir = parent;
300
+ }
301
+ return path.dirname(p);
302
+ }
303
+ function _attachRecoveryArtifacts(e, candidates, expected, planned, targetPath) {
304
+ const seen = new Set();
305
+ const artifacts = [];
306
+ for (const candidate of candidates) {
307
+ if (!candidate || !candidate.path || seen.has(candidate.path)) continue;
308
+ seen.add(candidate.path);
309
+ let stat;
310
+ try { stat = fs.lstatSync(candidate.path); } catch { continue; }
311
+ const artifact = {
312
+ role: candidate.role,
313
+ path: candidate.path,
314
+ kind: stat.isDirectory() ? 'directory' : 'file',
315
+ };
316
+ if (stat.isFile()) {
317
+ artifact.size = stat.size;
318
+ try {
319
+ const bytes = fs.readFileSync(candidate.path);
320
+ artifact.content = bytes.equals(expected) ? 'expected-original'
321
+ : (bytes.equals(planned) ? 'planned-replacement' : 'concurrent-or-unknown');
322
+ } catch (readError) {
323
+ artifact.content = 'unreadable';
324
+ artifact.readError = readError.code || readError.message;
325
+ }
326
+ }
327
+ artifacts.push(artifact);
328
+ }
329
+ if (!artifacts.length) return e;
330
+ e.recoveryArtifacts = artifacts;
331
+ const recovery = artifacts.find(a => a.path !== targetPath && a.content === 'concurrent-or-unknown')
332
+ || artifacts.find(a => a.path !== targetPath && a.role === 'displaced-original')
333
+ || artifacts.find(a => a.path !== targetPath && a.kind === 'file');
334
+ if (recovery && !e.backupFile) e.backupFile = recovery.path;
335
+ const detail = artifacts.map(a => `${a.role}=${a.path} (${a.content || a.kind})`).join(', ');
336
+ if (!e.message.includes('복구 아티팩트:')) e.message = `${e.message} (복구 아티팩트: ${detail})`;
337
+ return e;
338
+ }
339
+ // ReplaceFileW (via the inbox Windows PowerShell runtime) is the only portable
340
+ // primitive available to this zero-dependency package that atomically replaces
341
+ // the default stream while retaining the destination's ACLs and alternate data
342
+ // streams. A real backup path also lets the caller detect a change that landed
343
+ // in the final compare -> replace window and retain the displaced file object
344
+ // instead of attempting another inherently racy replacement.
345
+ function _replaceWindowsWithBackup(from, to, backup) {
346
+ const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
347
+ const powershell = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
348
+ const command = "$utf8 = New-Object System.Text.UTF8Encoding($false); [Console]::OutputEncoding = $utf8; $OutputEncoding = $utf8; $ErrorActionPreference='Stop'; try { [IO.File]::Replace($env:LEERNESS_REPLACE_FROM, $env:LEERNESS_REPLACE_TO, $env:LEERNESS_REPLACE_BACKUP) } catch { $e = $_.Exception; while ($e.InnerException) { $e = $e.InnerException }; $win32 = ($e.HResult -band 0xFFFF); [Console]::Error.WriteLine(('LEERNESS_REPLACE_ERROR:{0}:{1}' -f $win32, $e.Message)); exit 1 }";
349
+ let waited = 0;
350
+ let attempts = 0;
351
+ for (;;) {
352
+ attempts++;
353
+ const r = cp.spawnSync(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], {
354
+ encoding: 'utf8',
355
+ windowsHide: true,
356
+ timeout: 15000,
357
+ env: {
358
+ ...process.env,
359
+ LEERNESS_REPLACE_FROM: from,
360
+ LEERNESS_REPLACE_TO: to,
361
+ LEERNESS_REPLACE_BACKUP: backup,
362
+ },
363
+ });
364
+ if (!r.error && r.status === 0) return;
365
+ const raw = r.error ? r.error.message : String(r.stderr || r.stdout || `exit ${r.status}`).trim();
366
+ const marker = /LEERNESS_REPLACE_ERROR:(\d+):([\s\S]*)/.exec(raw);
367
+ const win32Code = marker ? Number(marker[1]) : null;
368
+ const detail = marker ? marker[2].trim() : raw;
369
+ const transientSharing = win32Code === 32 || win32Code === 33;
370
+ // Retry only a proven sharing/lock violation while ReplaceFileW has made
371
+ // no observable progress. An existing backup or consumed source makes the
372
+ // result uncertain, so the caller must preserve recovery data instead.
373
+ const retrySafe = transientSharing && waited < 2000 && fs.existsSync(from) && !fs.existsSync(backup);
374
+ if (!retrySafe) {
375
+ const e = new Error(`Windows 메타데이터 보존 교체 실패: ${detail}`);
376
+ e.code = (r.error && r.error.code) || 'E_ATOMIC_REPLACE';
377
+ e.win32Code = win32Code;
378
+ e.attempts = attempts;
379
+ throw e;
380
+ }
381
+ const delay = Math.min(50 * (2 ** Math.min(attempts - 1, 3)), 400, 2000 - waited);
382
+ _sleepMsSync(delay);
383
+ waited += delay;
384
+ }
385
+ }
386
+ // 1.36.177 (T-0022): compare-and-set binary write for mutators that must
387
+ // retain the original byte stream. The expected bytes are checked both before
388
+ // and immediately after preparing the temp file. Windows additionally uses a
389
+ // replacement backup to close the last compare -> replace race without losing
390
+ // ACLs/alternate streams. The displaced object remains as an explicit recovery
391
+ // artifact because an already-open Windows handle can write to it after our
392
+ // validation. Other platforms are rejected: Node's rename can
393
+ // replace a read-only inode and discard ownership, ACLs, xattrs, and hard-link
394
+ // identity, while Node exposes no portable metadata-preserving replacement.
395
+ function writeBufferIfUnchanged(p, expectedValue, value, opts = {}) {
396
+ _dryCheck(p);
397
+ if (process.platform !== 'win32') {
398
+ const e = new Error(`메타데이터 보존 원자 교체를 지원하지 않는 플랫폼입니다: ${process.platform}`);
399
+ e.code = 'E_METADATA_PRESERVATION_UNAVAILABLE';
400
+ throw e;
401
+ }
402
+ const expected = Buffer.isBuffer(expectedValue) ? expectedValue : Buffer.from(expectedValue);
403
+ const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
404
+ const expectedIdentity = opts.expectedIdentity || _readMutationIdentity(p);
405
+ _identityChanged(p, expectedIdentity);
406
+ let current = fs.readFileSync(p);
407
+ if (!current.equals(expected)) throw _concurrentModification(p);
408
+ if (current.equals(buf)) return { unchanged: true, recoveryArtifacts: [] };
409
+ mkdirp(path.dirname(p));
410
+ const mode = expectedIdentity.mode & 0o777;
411
+ // mkdtemp is an exclusive namespace reservation. Keeping next/before
412
+ // inside it prevents a parallel agent from pre-creating a predictable backup
413
+ // name that File.Replace would otherwise overwrite.
414
+ const recoveryParent = _mutationRecoveryParent(p, expectedIdentity);
415
+ let scratch = fs.mkdtempSync(path.join(recoveryParent, `.leerness-write-${process.pid}-`));
416
+ const tmp = path.join(scratch, 'next');
417
+ let backup = null;
418
+ let replaceAttempted = false;
419
+ try {
420
+ fs.writeFileSync(tmp, buf, { flag: 'wx', mode });
421
+ _identityChanged(p, expectedIdentity);
422
+ current = fs.readFileSync(p);
423
+ if (!current.equals(expected)) throw _concurrentModification(p);
424
+ backup = path.join(scratch, 'before');
425
+ replaceAttempted = true;
426
+ _replaceWindowsWithBackup(tmp, p, backup);
427
+ if (fs.existsSync(tmp)) {
428
+ const e = new Error(`Windows 교체가 성공을 보고했지만 replacement source가 남았습니다: ${tmp}`);
429
+ e.code = 'E_ATOMIC_REPLACE_UNCERTAIN';
430
+ throw e;
431
+ }
432
+ const replaced = fs.readFileSync(backup);
433
+ const displacedIdentity = _readMutationIdentity(backup);
434
+ const targetNow = fs.readFileSync(p);
435
+ if (!replaced.equals(expected) || !targetNow.equals(buf)
436
+ || !_sameMutationIdentity(displacedIdentity, expectedIdentity)
437
+ || displacedIdentity.nlink !== 1) {
438
+ throw _concurrentModification(p, backup);
439
+ }
440
+ // Keep the displaced file object intentionally. A normal Windows writer
441
+ // may already hold a delete-sharing handle and write to this object after
442
+ // our validation; removing it here would silently discard that late edit.
443
+ // Retention also preserves hard-link/ADS changes that cannot be proven by
444
+ // comparing the unnamed stream alone. Callers must surface backupFile.
445
+ return {
446
+ backupFile: backup,
447
+ recoveryArtifacts: [{ role: 'displaced-original', path: backup, kind: 'file', size: replaced.length, content: 'expected-original' }],
448
+ };
449
+ } catch (e) {
450
+ const cleanupError = !replaceAttempted ? _cleanupWriteArtifact(tmp) : null;
451
+ const scratchCleanupError = !replaceAttempted && !cleanupError ? _cleanupWriteScratch(scratch) : null;
452
+ if (cleanupError || scratchCleanupError) {
453
+ e.cleanupError = (cleanupError || scratchCleanupError).message;
454
+ }
455
+ _attachRecoveryArtifacts(e, replaceAttempted ? [
456
+ { role: 'live-target', path: p },
457
+ { role: 'planned-source', path: tmp },
458
+ { role: 'displaced-original', path: backup },
459
+ ] : [
460
+ ...(cleanupError ? [{ role: 'cleanup-failed-source', path: tmp }] : []),
461
+ ...(cleanupError || scratchCleanupError ? [{ role: 'cleanup-failed-scratch', path: scratch }] : []),
462
+ ], expected, buf, p);
463
+ throw e;
464
+ }
465
+ }
225
466
  // 1.36.132 (P1, 실측): Windows 에서 **락을 제대로 잡고 있어도** 원자적 교체가 실패한다 —
226
467
  // 읽기는 락을 잡지 않으므로, 다른 프로세스가 대상 파일을 연 채로 있으면 rename 이 EPERM/EACCES 를 낸다.
227
468
  // 실측: 32 프로세스 동시 `task add` → EPERM 3건, 그 3행이 통째로 유실되고 자식은 exit 1 로 죽었다
@@ -243,4 +484,4 @@ function _renameWithRetry(from, to) {
243
484
  function append(p, s) { _dryCheck(p); mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
244
485
  function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
245
486
 
246
- module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw };
487
+ module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, writeBufferIfUnchanged, append, rel, setDryRunGuard, mkdirpRaw };
@@ -7,6 +7,7 @@ const cp = require('child_process');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
9
  const fs = require('fs');
10
+ const { applyShellScriptUtf8Bom } = require('./shell-encoding');
10
11
  const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
11
12
  const { _sanitizeFences, _parseArchiveBlocks, _milestoneSurface, _recentChangesAggregate } = require('./pure-utils');
12
13
 
@@ -346,6 +347,42 @@ function sessionClose(root, opts = {}, deps = {}) {
346
347
  // 1.9.103: stdout 복원
347
348
  if (jsonMode) process.stdout.write = _origWrite;
348
349
  }
350
+ // 1.36.177 (T-0022): mutation must not depend on the renderer. Previously
351
+ // this lived only in the human-output branch, so `--json` accepted the flag,
352
+ // returned success, and silently skipped every file. Run once before either
353
+ // renderer and retain every applied/skipped/failed result for honest output.
354
+ let encodingAutoFix = null;
355
+ if (has('--auto-fix-encoding')) {
356
+ encodingAutoFix = { requested: true, scanned: 0, atRisk: 0, added: 0, skipped: 0, failed: 0, scanErrors: [], results: [] };
357
+ try {
358
+ const encScan = _scanShellScriptsEncoding(root);
359
+ encodingAutoFix.scanned = encScan.scanned || 0;
360
+ encodingAutoFix.atRisk = Array.isArray(encScan.atRisk) ? encScan.atRisk.length : 0;
361
+ encodingAutoFix.scanErrors = Array.isArray(encScan.scanErrors) ? encScan.scanErrors.slice() : [];
362
+ for (const scanError of encodingAutoFix.scanErrors) {
363
+ encodingAutoFix.failed++;
364
+ encodingAutoFix.results.push({ file: scanError.file, action: 'failed-scan', code: scanError.code, error: scanError.error, operation: scanError.operation });
365
+ }
366
+ for (const r of (encScan.atRisk || [])) {
367
+ try {
368
+ const applied = applyShellScriptUtf8Bom(path.join(root, r.file), r.file);
369
+ encodingAutoFix.results.push(applied);
370
+ if (applied.action === 'utf8-bom-added') encodingAutoFix.added++;
371
+ else if (applied.action.startsWith('skipped-')) encodingAutoFix.skipped++;
372
+ } catch (e) {
373
+ encodingAutoFix.failed++;
374
+ encodingAutoFix.results.push({ file: r.file, action: 'failed', code: e.code || 'error', error: e.message, ...(e.backupFile ? { backupFile: e.backupFile } : {}), ...(e.recoveryArtifacts ? { recoveryArtifacts: e.recoveryArtifacts } : {}) });
375
+ console.error(t(`⚠ 인코딩 자동 수정 거부: ${r.file} — ${e.message}`, `⚠ encoding auto-fix refused: ${r.file} — ${e.message}`));
376
+ }
377
+ }
378
+ } catch (e) {
379
+ encodingAutoFix.failed++;
380
+ encodingAutoFix.results.push({ file: null, action: 'failed', code: e.code || 'error', error: e.message, ...(e.backupFile ? { backupFile: e.backupFile } : {}), ...(e.recoveryArtifacts ? { recoveryArtifacts: e.recoveryArtifacts } : {}) });
381
+ console.error(t(`⚠ 인코딩 자동 수정 오류: ${e.message}`, `⚠ encoding auto-fix error: ${e.message}`));
382
+ }
383
+ if (encodingAutoFix.failed > 0) process.exitCode = 1;
384
+ jsonResult.encodingAutoFix = encodingAutoFix;
385
+ }
349
386
  // 1.9.103: JSON 모드 — 구조화 출력
350
387
  if (jsonMode) {
351
388
  try {
@@ -479,6 +516,8 @@ function sessionClose(root, opts = {}, deps = {}) {
479
516
  shellScriptsScanned: encScan.scanned,
480
517
  encodingRiskCount: encScan.atRisk.length,
481
518
  encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
519
+ encodingScanErrorCount: (encScan.scanErrors || []).length,
520
+ encodingScanErrors: (encScan.scanErrors || []).slice(0, 5),
482
521
  // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
483
522
  terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
484
523
  autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
@@ -672,22 +711,14 @@ function sessionClose(root, opts = {}, deps = {}) {
672
711
  // 1.9.224 (--auto-apply-delivered) / 1.9.237 (--auto-cleanup-branches) 패턴 확장
673
712
  // 마감 시 한국어/일본어/중국어 PowerShell 인코딩 위험 자동 BOM 추가
674
713
  try {
675
- const encScan = _scanShellScriptsEncoding(root);
676
- if (encScan.atRisk && encScan.atRisk.length > 0) {
677
- if (has('--auto-fix-encoding')) {
678
- let ok = 0;
679
- for (const r of encScan.atRisk) {
680
- try {
681
- const fullPath = path.join(root, r.file);
682
- const orig = fs.readFileSync(fullPath);
683
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
684
- const fixed = Buffer.concat([bom, orig]);
685
- fs.writeFileSync(fullPath, fixed);
686
- ok++;
687
- } catch {}
688
- }
689
- log(grn(t(` ✓ 인코딩 위험 ${ok}/${encScan.atRisk.length}건 UTF-8 BOM 자동 추가 (--auto-fix-encoding 1.9.243)`, ` ✓ ${ok}/${encScan.atRisk.length} encoding risk(s) auto-fixed with UTF-8 BOM (--auto-fix-encoding)`)));
690
- } else {
714
+ if (encodingAutoFix) {
715
+ const a = encodingAutoFix;
716
+ if (a.atRisk > 0) log(grn(t(` ✓ 인코딩 위험 ${a.added}/${a.atRisk}건 UTF-8 BOM 자동 추가${a.skipped > 0 ? ` · 안전상 건너뜀 ${a.skipped}건` : ''}${a.failed > 0 ? ` · 실패 ${a.failed}건` : ''} (--auto-fix-encoding 1.9.243)`, ` ✓ ${a.added}/${a.atRisk} encoding risk(s) auto-fixed${a.skipped > 0 ? ` · safely skipped ${a.skipped}` : ''}${a.failed > 0 ? ` · ${a.failed} failed` : ''} (--auto-fix-encoding)`)));
717
+ for (const r of a.results.filter(r => r.backupFile)) log(yel(t(` ↳ 원본/동시 편집 복구본 보존: ${r.backupFile}`, ` ↳ retained original/concurrent recovery copy: ${r.backupFile}`)));
718
+ } else {
719
+ const encScan = _scanShellScriptsEncoding(root);
720
+ if (encScan.scanErrors && encScan.scanErrors.length > 0) log(yel(t(` ⚠ 인코딩 스캔 읽기 실패 ${encScan.scanErrors.length}건 — 결과 불완전`, ` ⚠ ${encScan.scanErrors.length} encoding scan read error(s) — result incomplete`)));
721
+ if (encScan.atRisk && encScan.atRisk.length > 0) {
691
722
  log(yel(t(` ⚠ 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 (1.9.241) — 자동 회복 가능`, ` ⚠ ${encScan.atRisk.length} shell-script encoding risk(s) — auto-fixable`)));
692
723
  log(dim(t(` → leerness env encoding --apply (수동) 또는 session close --auto-fix-encoding (1.9.243 자동)`, ` → leerness env encoding --apply (manual) or session close --auto-fix-encoding (auto)`)));
693
724
  }
@@ -0,0 +1,175 @@
1
+ // lib/shell-encoding.js — shared precondition + atomic mutation for UTF-8 BOM fixes.
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { writeBufferIfUnchanged } = require('./io');
7
+
8
+ const UTF8_BOM = Buffer.from([0xEF, 0xBB, 0xBF]);
9
+
10
+ function _validUtf8(buf) {
11
+ return Buffer.from(buf.toString('utf8'), 'utf8').equals(buf);
12
+ }
13
+
14
+ function _validCp949(buf) {
15
+ let decoder;
16
+ try {
17
+ // WHATWG euc-kr is the Windows-compatible unified Korean decoder. Node 18+
18
+ // normally ships it through ICU. Small-ICU/custom builds may not expose the
19
+ // decoder at all; that is "unknown", not proof that the bytes are invalid
20
+ // CP949. `fatal` matters because replacement characters would turn arbitrary
21
+ // bytes into false encoding provenance.
22
+ decoder = new TextDecoder('euc-kr', { fatal: true });
23
+ } catch {
24
+ return null;
25
+ }
26
+ try {
27
+ decoder.decode(buf);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ // Detection and mutation are deliberately joined at the final write boundary:
35
+ // callers may act on a scan result that is already stale. Re-read the current
36
+ // bytes and re-check every destructive precondition immediately before writing.
37
+ function planShellScriptUtf8Bom(file, input) {
38
+ const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
39
+ const ext = path.extname(String(file || '')).toLowerCase();
40
+ if (buf.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)) {
41
+ if (!_validUtf8(buf.subarray(UTF8_BOM.length))) {
42
+ return { action: 'skipped-invalid-bom-body (UTF-8 BOM 뒤 본문이 유효한 UTF-8이 아님 — 기존 손상 가능성, 수동 복구 필요)' };
43
+ }
44
+ return { action: 'skipped-existing-bom' };
45
+ }
46
+ const shebang = buf.length >= 2 && buf[0] === 0x23 && buf[1] === 0x21;
47
+ if (ext === '.sh' || shebang) {
48
+ return { action: 'skipped-shebang (BOM은 shebang을 깨뜨림 — .sh는 no-BOM UTF-8 유지)' };
49
+ }
50
+ if (ext === '.bat' || ext === '.cmd') {
51
+ return { action: 'skipped-batch (cmd.exe: 첫 줄에 chcp 65001 추가가 정답 — BOM은 코드페이지를 안 바꿈)' };
52
+ }
53
+ if (ext !== '.ps1') {
54
+ return { action: 'skipped-unsupported (자동 BOM 적용 대상은 .ps1만 지원)' };
55
+ }
56
+ if (!buf.some((byte) => byte >= 0x80)) {
57
+ return { action: 'skipped-ascii (비-ASCII 인코딩 위험 없음)' };
58
+ }
59
+ if (!_validUtf8(buf)) {
60
+ return { action: 'skipped-nonutf8 (본문이 비-UTF-8(CP949 등) — BOM 추가는 손상. UTF-8 로 먼저 transcode 필요)' };
61
+ }
62
+ const cp949Validity = _validCp949(buf);
63
+ if (cp949Validity !== false) {
64
+ return { action: cp949Validity === true
65
+ ? 'skipped-ambiguous-encoding (UTF-8과 CP949 모두로 유효하지만 의미가 다를 수 있음 — 명시적 transcode 후 재시도)'
66
+ : 'skipped-ambiguous-encoding (CP949 decoder unavailable — 레거시 인코딩이 아님을 증명할 수 없어 자동 수정 거부)' };
67
+ }
68
+ return { action: 'utf8-bom-added', bytes: Buffer.concat([UTF8_BOM, buf]) };
69
+ }
70
+
71
+ function scanShellScriptsEncoding(root, opts = {}) {
72
+ const classifyCJK = typeof opts.classifyCJK === 'function'
73
+ ? opts.classifyCJK : () => ({ korean: 0, japanese: 0, chinese: 0, other: 0 });
74
+ const riskLabel = typeof opts.riskLabel === 'function'
75
+ ? opts.riskLabel : () => ({ type: 'non-ascii', risk: 'BOM 없는 비-ASCII 셸 스크립트' });
76
+ const result = { scanned: 0, atRisk: [], scanErrors: [], notes: [] };
77
+ const scanError = (operation, fp, e) => result.scanErrors.push({
78
+ operation,
79
+ file: path.relative(root, fp) || '.',
80
+ code: e && e.code ? e.code : 'error',
81
+ error: e && e.message ? e.message : String(e),
82
+ });
83
+ function walk(dir, depth = 0) {
84
+ if (depth > 3) return;
85
+ let entries;
86
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { scanError('readdir', dir, e); return; }
87
+ for (const entry of entries) {
88
+ const fp = path.join(dir, entry.name);
89
+ if (entry.isDirectory()) {
90
+ if (/^(node_modules|\.git|__pycache__|venv|\.venv|dist|build)$/.test(entry.name)) continue;
91
+ walk(fp, depth + 1);
92
+ continue;
93
+ }
94
+ if (!entry.isFile() || !/\.(ps1|bat|cmd|sh)$/i.test(entry.name)) continue;
95
+ try {
96
+ const buf = fs.readFileSync(fp);
97
+ result.scanned++;
98
+ const hasBOM = buf.subarray(0, UTF8_BOM.length).equals(UTF8_BOM);
99
+ const plan = planShellScriptUtf8Bom(fp, buf);
100
+ let nonAscii = false;
101
+ for (let i = hasBOM ? UTF8_BOM.length : 0; i < Math.min(buf.length, 4096); i++) {
102
+ if (buf[i] >= 0x80) { nonAscii = true; break; }
103
+ }
104
+ const invalidBomBody = plan.action.startsWith('skipped-invalid-bom-body');
105
+ if ((!hasBOM && nonAscii) || invalidBomBody) {
106
+ const cjk = classifyCJK(buf, 4096);
107
+ const label = riskLabel(cjk);
108
+ result.atRisk.push({
109
+ file: path.relative(root, fp),
110
+ ext: path.extname(fp),
111
+ hasBOM,
112
+ nonAscii: true,
113
+ riskType: invalidBomBody ? 'invalid-bom-body' : label.type,
114
+ cjk,
115
+ risk: invalidBomBody
116
+ ? 'UTF-8 BOM 뒤 본문이 유효한 UTF-8이 아님 — 기존 손상 가능성, 수동 복구 필요'
117
+ : label.risk,
118
+ fixAction: plan.action,
119
+ });
120
+ }
121
+ } catch (e) {
122
+ scanError('read', fp, e);
123
+ }
124
+ }
125
+ }
126
+ walk(root);
127
+ if (result.atRisk.length > 0) {
128
+ result.notes.push('해결: 인코딩 출처가 명확한 .ps1만 UTF-8로 transcode 후 BOM을 추가. UTF-8/CP949 양쪽으로 유효한 본문은 자동 수정하지 않음');
129
+ }
130
+ if (result.scanErrors.length > 0) result.notes.push('읽지 못한 경로가 있어 검사가 불완전함 — 권한/잠금을 해결한 뒤 다시 실행');
131
+ result.riskTypeCounts = result.atRisk.reduce((m, r) => {
132
+ m[r.riskType] = (m[r.riskType] || 0) + 1;
133
+ return m;
134
+ }, {});
135
+ return result;
136
+ }
137
+
138
+ function applyShellScriptUtf8Bom(fullPath, displayFile = fullPath) {
139
+ const linkStat = fs.lstatSync(fullPath);
140
+ if (linkStat.isSymbolicLink()) return { file: displayFile, action: 'skipped-symlink (링크 대상을 자동 교체하지 않음)' };
141
+ if (!linkStat.isFile()) return { file: displayFile, action: 'skipped-nonfile (일반 파일만 자동 교체)' };
142
+ if (linkStat.nlink > 1) return { file: displayFile, action: 'skipped-multiple-links (하드링크 별칭 불일치 방지)' };
143
+ const original = fs.readFileSync(fullPath);
144
+ const afterRead = fs.lstatSync(fullPath);
145
+ if (afterRead.isSymbolicLink() || afterRead.dev !== linkStat.dev || afterRead.ino !== linkStat.ino
146
+ || afterRead.nlink !== 1 || afterRead.mode !== linkStat.mode) {
147
+ const e = new Error(`읽는 동안 파일 정체성/링크/권한이 변경되어 자동 수정을 거부했습니다: ${displayFile}`);
148
+ e.code = 'E_CONCURRENT_MODIFICATION';
149
+ throw e;
150
+ }
151
+ const plan = planShellScriptUtf8Bom(fullPath, original);
152
+ if (plan.action !== 'utf8-bom-added') return { file: displayFile, action: plan.action };
153
+ // BOM is a Windows PowerShell compatibility repair. On POSIX, replacing an
154
+ // inode atomically with Node cannot retain arbitrary ACLs/xattrs/ownership;
155
+ // fail closed instead of claiming a safe repair while discarding metadata.
156
+ if (process.platform !== 'win32') return { file: displayFile, action: 'skipped-platform (Windows 메타데이터 보존 교체만 지원)' };
157
+ // Writability is a mutation precondition, not a scan/skip precondition. A
158
+ // read-only .sh or a POSIX .ps1 is still a successful byte-exact no-op.
159
+ if ((linkStat.mode & 0o222) === 0) {
160
+ const e = new Error(`읽기 전용 파일은 자동 수정하지 않습니다: ${displayFile}`);
161
+ e.code = 'E_READ_ONLY';
162
+ throw e;
163
+ }
164
+ const written = writeBufferIfUnchanged(fullPath, original, plan.bytes, {
165
+ expectedIdentity: { dev: linkStat.dev, ino: linkStat.ino, nlink: linkStat.nlink, mode: linkStat.mode },
166
+ });
167
+ return {
168
+ file: displayFile,
169
+ action: plan.action,
170
+ ...(written && written.backupFile ? { backupFile: written.backupFile } : {}),
171
+ ...(written && written.recoveryArtifacts ? { recoveryArtifacts: written.recoveryArtifacts } : {}),
172
+ };
173
+ }
174
+
175
+ module.exports = { planShellScriptUtf8Bom, scanShellScriptsEncoding, applyShellScriptUtf8Bom };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.176",
3
+ "version": "1.36.178",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -48,13 +48,13 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
51
+ "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
53
53
  "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
55
55
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
56
56
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
57
- "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js",
57
+ "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js",
58
58
  "test:false-claims": "node ./scripts/false-claim-probe.js",
59
59
  "test:workspace-lock-order": "node ./scripts/workspace-dir-lock-order-probe.js",
60
60
  "test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
@@ -122,12 +122,23 @@ console.log('# leerness core (test:core) — flagship behavioral guarantees');
122
122
  cp.spawnSync(process.execPath, [CLI, 'env', 'encoding-check', '--apply', '--path', d], { encoding: 'utf8', timeout: 40000 });
123
123
  const after = fs.readFileSync(path.join(d, 'cp949.ps1'));
124
124
  assert('encoding --apply: CP949 .ps1 NOT mutated (no destructive BOM-on-CP949)', after.equals(cp949) && !(after[0] === 0xEF && after[1] === 0xBB && after[2] === 0xBF));
125
- // 유효 UTF-8 .ps1(한글, no BOM)은 정당하게 BOM 추가되어야 함
126
- fs.writeFileSync(path.join(d, 'utf8.ps1'), Buffer.from('Write-Host "안녕"\r\n', 'utf8'));
125
+ // UTF-8/CP949 양쪽으로 유효한 바이트는 의미가 다를 수 있어 보수적으로 skip.
126
+ const ambiguous = Buffer.from([0x41, 0xC2, 0xA1, 0x42]); // UTF-8 A¡B / CP949 A징B
127
+ fs.writeFileSync(path.join(d, 'ambiguous.ps1'), ambiguous);
128
+ // Emoji의 0x80 trail은 CP949에서 무효이므로 UTF-8 출처를 바이트로 증명할 수 있다.
129
+ const utf8Only = Buffer.from('Write-Host "😀"\r\n', 'utf8');
130
+ fs.writeFileSync(path.join(d, 'utf8.ps1'), utf8Only);
127
131
  cp.spawnSync(process.execPath, [CLI, 'env', 'encoding-check', '--apply', '--path', d], { encoding: 'utf8', timeout: 40000 });
128
132
  const u = fs.readFileSync(path.join(d, 'utf8.ps1'));
133
+ const a = fs.readFileSync(path.join(d, 'ambiguous.ps1'));
129
134
  const uValid = Buffer.from(u.toString('utf8'), 'utf8').equals(u);
130
- assert('encoding --apply: valid-UTF-8 .ps1 gets BOM (still valid)', u[0] === 0xEF && u[1] === 0xBB && u[2] === 0xBF && uValid);
135
+ assert('encoding --apply: UTF-8/CP949 ambiguous .ps1 remains exact', a.equals(ambiguous));
136
+ assert('encoding --apply: unambiguous UTF-8 .ps1 follows platform contract', process.platform === 'win32'
137
+ ? (u[0] === 0xEF && u[1] === 0xBB && u[2] === 0xBF && uValid)
138
+ : u.equals(utf8Only));
139
+ for (const e of fs.readdirSync(d, { withFileTypes: true }).filter(e => e.isDirectory() && e.name.startsWith('.leerness-write-'))) {
140
+ fs.rmSync(path.join(d, e.name), { recursive: true, force: true });
141
+ }
131
142
  fs.rmSync(d, { recursive: true, force: true });
132
143
  }
133
144