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/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/bin/leerness.js +47 -77
- package/lib/drift.js +525 -360
- package/lib/health.js +7 -4
- package/lib/io.js +242 -1
- package/lib/session-close.js +47 -16
- package/lib/shell-encoding.js +175 -0
- package/package.json +4 -4
- package/scripts/e2e-core.js +14 -3
- package/scripts/e2e.js +14 -8
- package/scripts/mutation-integrity-probe.js +536 -0
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 };
|
package/lib/session-close.js
CHANGED
|
@@ -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
|
-
|
|
676
|
-
|
|
677
|
-
if (
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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.
|
|
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",
|
package/scripts/e2e-core.js
CHANGED
|
@@ -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
|
-
//
|
|
126
|
-
|
|
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:
|
|
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
|
|