leerness 1.9.3 → 1.9.4

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.4 — 2026-05-08
4
+
5
+ 1.9.3 운영 중 발견된 5개 한계점을 모두 패치합니다.
6
+
7
+ ### Fixed
8
+
9
+ - **A. impact 정확도**: 강한 참조(`import / require / @import / href / src / url / include`)와 약한 참조(식별자 등장)를 분리해 default는 강한 참조만 출력. word boundary 추가로 `cards` 안의 `card`가 false positive로 잡히던 문제 해결. `--all`로 약한 참조까지 표시.
10
+ - **B. cross-platform 종료 코드**: main이 끝난 뒤 `process.exit(process.exitCode)`을 명시. 셸 wrapper나 npx 파이프라인에서 `$?`이 0으로 보이던 문제 해결. `ui consistency --fail-on-violation`은 `--strict-exit`로 즉시 `process.exit(1)`도 가능.
11
+ - **C. lazy detect string literal 휴리스틱**: 매치 위치가 `'…'`/`"…"`/`` `…` `` 안이면 카운트에서 제외. leerness CLI 자기 자신(bin/harness.js)도 자동 skip. 메인 디렉토리에서 30개 잡히던 false positive 사실상 0.
12
+
13
+ ### Added
14
+
15
+ - **D. `leerness task fix-evidence`** — `done` 상태이면서 evidence가 비어있거나 `user-request` / `plan:M-XXXX` 단독인 row를 일괄 점검. `--set "<텍스트>"`로 일괄 갱신, 또는 row별 `task update` 명령을 출력해 가이드.
16
+ - **E. `.leerness-skip-dirs` 파일** — 프로젝트 루트에 두면 추가 skip 디렉토리(예: `_apps/`, `leerness-pkg/`)가 모든 walk에서 적용됨. 1줄당 1개 디렉토리, `#` 주석 지원. 기본 skip 셋에도 `out`, `tmp`, `temp`, `.svelte-kit`, `.parcel-cache` 추가.
17
+
18
+ ### Migration
19
+
20
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
21
+
3
22
  ## 1.9.3 — 2026-05-08
4
23
 
5
24
  이번 릴리스는 "이전 작업과 새 작업의 인과관계·재귀 안내·디자인 일관성"을 자동화합니다.
package/bin/harness.js CHANGED
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const cp = require('child_process');
7
7
  const readline = require('readline');
8
8
 
9
- const VERSION = '1.9.3';
9
+ const VERSION = '1.9.4';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -679,6 +679,37 @@ function taskDrop(root, id) {
679
679
  ok(`task dropped: ${id}`);
680
680
  }
681
681
 
682
+ // 1.9.4 D: evidence가 placeholder인 done row를 일괄 점검.
683
+ function taskFixEvidence(root) {
684
+ root = absRoot(root);
685
+ const rows = readProgressRows(root);
686
+ const candidates = rows.filter(r =>
687
+ r.status === 'done' && (
688
+ !r.evidence ||
689
+ /^\s*$/.test(r.evidence) ||
690
+ /^(user-request|-)$/.test(r.evidence) ||
691
+ /^plan:M-\d{4}\s*$/.test(r.evidence)
692
+ )
693
+ );
694
+ if (!candidates.length) return ok('갱신 후보 없음 (모든 done row가 검증 키워드 보유)');
695
+ const setAll = arg('--set', null);
696
+ if (setAll) {
697
+ for (const r of candidates) upsertProgress(root, { id: r.id, evidence: setAll });
698
+ ok(`${candidates.length}개 row의 evidence를 일괄 갱신`);
699
+ return;
700
+ }
701
+ log(`# task fix-evidence — ${candidates.length}개 후보`);
702
+ log(`아래 row들은 evidence가 검증 키워드(테스트/명령/결과)를 포함하지 않습니다.`);
703
+ log(`각각 다음 명령으로 갱신하거나, --set "<공통 텍스트>"로 일괄 갱신하세요.\n`);
704
+ for (const r of candidates) {
705
+ log(`leerness task update ${r.id} --evidence "검증 결과 (e.g., npm test 통과)"`);
706
+ log(` 요청: ${r.request}`);
707
+ log(` 현재 evidence: "${r.evidence || ''}"`);
708
+ log('');
709
+ }
710
+ if (has('--fail-on-candidates')) process.exit(1);
711
+ }
712
+
682
713
  function route(name) {
683
714
  const r = routes[name];
684
715
  if (!r) { fail('Unknown route'); log('Available: ' + Object.keys(routes).join(', ')); return; }
@@ -771,15 +802,26 @@ const SECRET_PATTERNS = [
771
802
  { name: 'Generic private key', re: /-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----/g },
772
803
  { name: 'Hardcoded password assignment', re: /\b(?:password|passwd|pwd|secret|api_key|apikey)\s*[:=]\s*["'][^"'\s]{6,}["']/gi },
773
804
  ];
774
- const SCAN_SKIP_DIRS = new Set(['.git','node_modules','.harness/archive','.viewwork','dist','build','.next','.turbo','.cache','coverage','_pkg-source']);
805
+ const SCAN_SKIP_DIRS = new Set(['.git','node_modules','.harness/archive','.viewwork','dist','build','.next','.turbo','.cache','coverage','_pkg-source','out','tmp','temp','.svelte-kit','.parcel-cache']);
806
+ // 1.9.4 E: .leerness-skip-dirs 파일에서 추가 skip 디렉토리 읽기
807
+ function getExtraSkipDirs(root) {
808
+ const f = path.join(absRoot(root || '.'), '.leerness-skip-dirs');
809
+ if (!exists(f)) return [];
810
+ return read(f).split('\n').map(s => s.trim().replace(/\/+$/, '')).filter(s => s && !s.startsWith('#'));
811
+ }
812
+ function isSkippedRel(rel, extras = []) {
813
+ const all = [...SCAN_SKIP_DIRS, ...extras];
814
+ return all.some(d => rel === d || rel.startsWith(d + '/'));
815
+ }
775
816
  const SCAN_TEXT_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.json','.md','.txt','.env','.bash','.sh','.yml','.yaml','.toml','.ini','.cfg','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php','.sql','.html','.css','.scss','.less','.xml','.bat','.ps1','']);
776
- function* walk(root, base = root, depth = 0) {
817
+ function* walk(root, base = root, depth = 0, extras = null) {
777
818
  if (depth > 12) return;
819
+ if (extras === null) extras = getExtraSkipDirs(root);
778
820
  for (const e of fs.readdirSync(base, { withFileTypes: true })) {
779
821
  const p = path.join(base, e.name);
780
822
  const r = path.relative(root, p).replace(/\\/g, '/');
781
- if (Array.from(SCAN_SKIP_DIRS).some(d => r === d || r.startsWith(d + '/'))) continue;
782
- if (e.isDirectory()) yield* walk(root, p, depth + 1);
823
+ if (isSkippedRel(r, extras)) continue;
824
+ if (e.isDirectory()) yield* walk(root, p, depth + 1, extras);
783
825
  else yield p;
784
826
  }
785
827
  }
@@ -871,12 +913,32 @@ function lazyDetect(root) {
871
913
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
872
914
  const hasTestRun = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress)\b/i.test(ev);
873
915
  if (!hasTestRun) { issues++; warn('review-evidence.md has no recorded test/typecheck/lint run'); }
916
+ // 1.9.4 C: TODO/FIXME가 string literal 안에 있으면 제외 (정규식 패턴 자체 등 false positive).
917
+ function isInsideQuote(line, idx) {
918
+ const pre = line.slice(0, idx);
919
+ const sq = (pre.match(/(?<!\\)'/g) || []).length;
920
+ const dq = (pre.match(/(?<!\\)"/g) || []).length;
921
+ const bq = (pre.match(/(?<!\\)`/g) || []).length;
922
+ return (sq % 2 === 1) || (dq % 2 === 1) || (bq % 2 === 1);
923
+ }
874
924
  let todoCount = 0;
925
+ const cliSelf = path.resolve(__filename);
875
926
  for (const file of walk(root)) {
876
927
  const ext = path.extname(file).toLowerCase();
877
- if (!SCAN_TEXT_EXT.has(ext) || file.includes('.harness') || file.includes('harness.js')) continue;
928
+ if (!SCAN_TEXT_EXT.has(ext)) continue;
929
+ if (file.includes('.harness')) continue;
930
+ if (path.resolve(file) === cliSelf) continue;
931
+ if (/[\\/]bin[\\/]harness\.js$/.test(file)) continue;
878
932
  let text; try { text = read(file); } catch { continue; }
879
- todoCount += (text.match(/\bTODO\b|\bFIXME\b|\bXXX\b/g) || []).length;
933
+ const lines = text.split('\n');
934
+ const tre = /\bTODO\b|\bFIXME\b|\bXXX\b/g;
935
+ for (const line of lines) {
936
+ tre.lastIndex = 0;
937
+ let m;
938
+ while ((m = tre.exec(line))) {
939
+ if (!isInsideQuote(line, m.index)) todoCount++;
940
+ }
941
+ }
880
942
  }
881
943
  if (todoCount > 0) {
882
944
  const hasTodoTask = rows.some(r => /TODO|FIXME|XXX/.test(r.request) || /TODO|FIXME|XXX/i.test(r.evidence));
@@ -1064,13 +1126,14 @@ function gate(root) {
1064
1126
 
1065
1127
  // ===== 1.9.3: Causal / reuse / consistency =====
1066
1128
  const CODE_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.css','.scss','.sass','.less','.html','.htm','.vue','.svelte','.md','.json','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php']);
1067
- function* walkCode(root, base = root, depth = 0) {
1129
+ function* walkCode(root, base = root, depth = 0, extras = null) {
1068
1130
  if (depth > 12) return;
1131
+ if (extras === null) extras = getExtraSkipDirs(root);
1069
1132
  for (const e of fs.readdirSync(base, { withFileTypes: true })) {
1070
1133
  const p = path.join(base, e.name);
1071
1134
  const r = path.relative(root, p).replace(/\\/g, '/');
1072
- if (Array.from(SCAN_SKIP_DIRS).some(d => r === d || r.startsWith(d + '/'))) continue;
1073
- if (e.isDirectory()) yield* walkCode(root, p, depth + 1);
1135
+ if (isSkippedRel(r, extras)) continue;
1136
+ if (e.isDirectory()) yield* walkCode(root, p, depth + 1, extras);
1074
1137
  else if (CODE_EXT.has(path.extname(p).toLowerCase())) yield p;
1075
1138
  }
1076
1139
  }
@@ -1082,25 +1145,44 @@ function impactCmd(root, target) {
1082
1145
  const abs = path.isAbsolute(target) ? target : path.resolve(root, target);
1083
1146
  const base = path.basename(target);
1084
1147
  const noext = path.basename(target, path.extname(target));
1085
- const direct = [];
1086
1148
  const targetRel = rel(root, abs);
1149
+ // 1.9.4 A: strong (import-style, 확신도 높음) vs weak (단순 식별자, 가능성 있음) 구분.
1150
+ const strongRe = new RegExp(
1151
+ `(?:` +
1152
+ `import\\s+[^;\\n]*?from\\s+['"][^'"]*${escapeRegex(base)}` +
1153
+ `|require\\(\\s*['"][^'"]*${escapeRegex(base)}` +
1154
+ `|@import\\s+['"][^'"]*${escapeRegex(base)}` +
1155
+ `|href=["'][^"']*${escapeRegex(base)}` +
1156
+ `|src=["'][^"']*${escapeRegex(base)}` +
1157
+ `|url\\(\\s*['"]?[^'")]*${escapeRegex(base)}` +
1158
+ `|include\\(\\s*['"][^'"]*${escapeRegex(base)}` +
1159
+ `)`
1160
+ );
1161
+ // word boundary 강화: cards 안의 card는 매치 안 함.
1162
+ const weakRe = new RegExp(`(?<![A-Za-z0-9_])${escapeRegex(noext)}(?![A-Za-z0-9_])`);
1163
+ const high = []; const low = [];
1087
1164
  for (const f of walkCode(root)) {
1088
1165
  if (path.resolve(f) === path.resolve(abs)) continue;
1089
1166
  let text; try { text = read(f); } catch { continue; }
1090
- const patterns = [
1091
- new RegExp(`(?:import\\s+[^;\\n]*?from\\s+['"]|require\\(['"]|@import\\s+['"]|href=["']|src=["']|url\\(\\s*['"]?|include\\(['"]?)[^'")\\s]*${escapeRegex(base)}`),
1092
- // bare reference to filename without extension (e.g., 'card' alias)
1093
- new RegExp(`['"][^'"\\n]*${escapeRegex(noext)}\\b[^'"\\n]*['"]`)
1094
- ];
1095
- if (patterns.some(re => re.test(text))) direct.push(rel(root, f));
1167
+ if (strongRe.test(text)) high.push(rel(root, f));
1168
+ else if (weakRe.test(text)) low.push(rel(root, f));
1096
1169
  }
1097
1170
  log(`# impact: ${targetRel}`);
1098
- if (direct.length === 0) ok('영향 범위 없음 (참조하는 파일 없음)');
1171
+ const showAll = has('--all');
1172
+ if (high.length === 0 && (low.length === 0 || !showAll)) ok('영향 범위 없음 (강한 참조 없음)');
1099
1173
  else {
1100
- log(`참조하는 파일 ${direct.length}개:`);
1101
- direct.forEach(d => log('- ' + d));
1174
+ if (high.length) {
1175
+ log(`강한 참조 ${high.length}개 (import/require/href/src/@import/url/include):`);
1176
+ high.forEach(d => log(' - ' + d));
1177
+ } else log('강한 참조: 없음');
1178
+ if (showAll && low.length) {
1179
+ log(`\n약한 참조 ${low.length}개 (식별자 등장 — false positive 가능, 확인 필요):`);
1180
+ low.forEach(d => log(' · ' + d));
1181
+ } else if (low.length && !showAll) {
1182
+ log(`\n💡 약한 참조 ${low.length}개 (--all 로 표시, 정확도는 떨어짐)`);
1183
+ }
1102
1184
  }
1103
- return { target: targetRel, direct };
1185
+ return { target: targetRel, high, low };
1104
1186
  }
1105
1187
 
1106
1188
  function reuseMapPath(root) { return path.join(root, '.harness/reuse-map.md'); }
@@ -1206,7 +1288,8 @@ function uiConsistency(root) {
1206
1288
  warn(`토큰 외 값 ${findings.length}개:`);
1207
1289
  for (const f of findings.slice(0, 30)) log(` ${f.file}:${f.line} ${f.value} (${f.type})`);
1208
1290
  if (findings.length > 30) log(` ... +${findings.length - 30}개`);
1209
- if (has('--fail-on-violation')) process.exitCode = 1;
1291
+ // 1.9.4 B: cross-platform 종료 코드 명시
1292
+ if (has('--fail-on-violation')) { process.exitCode = 1; if (has('--strict-exit')) process.exit(1); }
1210
1293
  }
1211
1294
 
1212
1295
  function graphCmd(root) {
@@ -1428,7 +1511,7 @@ function viewworkInstall(root) {
1428
1511
  }
1429
1512
 
1430
1513
  function help() {
1431
- log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy\n leerness impact <target> # 변경 전 영향 분석\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
1514
+ log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
1432
1515
  }
1433
1516
 
1434
1517
  async function main() {
@@ -1491,8 +1574,12 @@ async function main() {
1491
1574
  if (sub==='add') return taskAdd(root, args.slice(2).join(' ') || '새 작업');
1492
1575
  if (sub==='update') return taskUpdate(root, args[2]);
1493
1576
  if (sub==='drop') return taskDrop(root, args[2]);
1577
+ if (sub==='fix-evidence') return taskFixEvidence(root);
1494
1578
  }
1495
1579
  return help();
1496
1580
  }
1497
1581
 
1498
- main().catch(err => { fail(err && err.message ? err.message : String(err)); process.exitCode = 1; });
1582
+ // 1.9.4 B: main 종료 exitCode를 명시적으로 process.exit으로 강제 (셸/wrapper 무시).
1583
+ main()
1584
+ .then(() => { if (process.exitCode && process.exitCode !== 0) process.exit(process.exitCode); })
1585
+ .catch(err => { fail(err && err.message ? err.message : String(err)); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.3",
3
+ "version": "1.9.4",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -223,6 +223,83 @@ total++;
223
223
  run('graph: mermaid 출력', ['graph', tmp]);
224
224
  run('guide: 통합 가이드', ['guide', 'src/components/Card.html', '--path', tmp]);
225
225
 
226
+ // 1.9.4 회귀: impact strong/weak 구분
227
+ total++;
228
+ {
229
+ // home.html에 "Card"라는 식별자가 plain text로 들어가도록 (false positive 시드)
230
+ fs.appendFileSync(path.join(tmp, 'src/pages/home.html'), '\n<!-- 카드 콘텐츠는 Cards 배열로 -->\n');
231
+ const r = cp.spawnSync(process.execPath, [CLI, 'impact', 'src/components/Card.html', '--path', tmp], { encoding: 'utf8' });
232
+ // strong만 잡혀야 하므로 home.html이 강한 참조에 들어가야 함 (이미 <include src=Card.html>)
233
+ // weak에는 about/contact가 들어갈 수 있지만 기본 출력은 strong만
234
+ const strongOK = /강한 참조 \d+개/.test(r.stdout);
235
+ const weakHint = /약한 참조|영향 범위 없음/.test(r.stdout);
236
+ console.log(strongOK && weakHint ? '✓ B(A) impact: strong/weak 구분 출력' : '✗ B(A) impact 구분 실패');
237
+ if (!(strongOK && weakHint)) failed++;
238
+ }
239
+
240
+ // 1.9.4 회귀: --fail-on-violation cross-platform 종료
241
+ total++;
242
+ {
243
+ fs.appendFileSync(path.join(tmp, 'src/pages/home.html'), '\n<style>.x{color:#cafe00;}</style>\n');
244
+ const r = cp.spawnSync(process.execPath, [CLI, 'ui', 'consistency', tmp, '--fail-on-violation'], { encoding: 'utf8' });
245
+ const ok = r.status === 1;
246
+ console.log(ok ? '✓ B(B) ui consistency --fail-on-violation: exit=1' : `✗ B(B) exit=${r.status}`);
247
+ if (!ok) failed++;
248
+ }
249
+
250
+ // 1.9.4 회귀: lazy detect string literal 무시
251
+ total++;
252
+ {
253
+ // false positive 시드: TODO 단어가 string literal 안에 있는 코드
254
+ fs.writeFileSync(path.join(tmp, 'src/regex-helper.js'), `module.exports = { TODO_RE: /\\bTODO\\b/g, label: 'TODO list' };\n`);
255
+ // 다른 한편 진짜 TODO 주석
256
+ fs.writeFileSync(path.join(tmp, 'src/real-todo.js'), `// TODO: 실제 미완료 작업\nconst x = 1;\n`);
257
+ const r = cp.spawnSync(process.execPath, [CLI, 'lazy', 'detect', tmp], { encoding: 'utf8' });
258
+ // string literal 안의 TODO는 무시되고, 주석 안의 진짜 TODO만 카운트되어야 함
259
+ const todosLine = (r.stdout.match(/code has (\d+) TODO/) || [0,'-'])[1];
260
+ const ok = todosLine === '1' || todosLine === '-' || /lazy detect passed/.test(r.stdout);
261
+ console.log(ok ? `✓ B(C) lazy detect: string literal 무시 (count=${todosLine})` : `✗ B(C) count=${todosLine}`);
262
+ if (!ok) failed++;
263
+ fs.unlinkSync(path.join(tmp, 'src/regex-helper.js'));
264
+ fs.unlinkSync(path.join(tmp, 'src/real-todo.js'));
265
+ }
266
+
267
+ // 1.9.4 회귀: task fix-evidence 표시
268
+ total++;
269
+ {
270
+ // T-0001 evidence를 placeholder로 (test before가 'review-evidence:e2e' 였음 → 'user-request'로 바꿈)
271
+ const trackerPath = path.join(tmp, '.harness/progress-tracker.md');
272
+ let cur = fs.readFileSync(trackerPath, 'utf8');
273
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | user-request | next | 2026-05-08 |');
274
+ fs.writeFileSync(trackerPath, cur);
275
+ const r = cp.spawnSync(process.execPath, [CLI, 'task', 'fix-evidence', '--path', tmp], { encoding: 'utf8' });
276
+ const ok = r.status === 0 && /T-0001/.test(r.stdout) && /후보/.test(r.stdout);
277
+ console.log(ok ? '✓ B(D) task fix-evidence: 후보 표시' : '✗ B(D) 후보 표시 실패');
278
+ if (!ok) { failed++; console.log(r.stdout); }
279
+ }
280
+
281
+ // 1.9.4 회귀: --set 일괄 갱신
282
+ total++;
283
+ {
284
+ const r = cp.spawnSync(process.execPath, [CLI, 'task', 'fix-evidence', '--set', 'npm test 통과 (e2e)', '--path', tmp], { encoding: 'utf8' });
285
+ const tracker = fs.readFileSync(path.join(tmp, '.harness/progress-tracker.md'), 'utf8');
286
+ const ok = r.status === 0 && /npm test 통과 \(e2e\)/.test(tracker);
287
+ console.log(ok ? '✓ B(D2) task fix-evidence --set: 일괄 갱신' : '✗ B(D2) 일괄 갱신 실패');
288
+ if (!ok) failed++;
289
+ }
290
+
291
+ // 1.9.4 회귀: .leerness-skip-dirs 적용
292
+ total++;
293
+ {
294
+ fs.mkdirSync(path.join(tmp, '_devspace'), { recursive: true });
295
+ fs.writeFileSync(path.join(tmp, '_devspace/secret-config.js'), `const k = "ghp_${'a'.repeat(36)}";\n`);
296
+ fs.writeFileSync(path.join(tmp, '.leerness-skip-dirs'), '_devspace/\n# 주석은 무시\n');
297
+ const r = cp.spawnSync(process.execPath, [CLI, 'scan', 'secrets', tmp], { encoding: 'utf8' });
298
+ const ok = r.status === 0 && /no obvious secret patterns/.test(r.stdout);
299
+ console.log(ok ? '✓ B(E) .leerness-skip-dirs: _devspace 자동 skip' : `✗ B(E) skip 실패\n${r.stdout}`);
300
+ if (!ok) failed++;
301
+ }
302
+
226
303
  run('gate (all checks)', ['gate', tmp]);
227
304
 
228
305
  run('self check (= update --check)', ['self', 'check', tmp], { });