leerness 1.9.4 → 1.9.6

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.6 — 2026-05-08
4
+
5
+ 1.9.5 후 발견된 한 가지 한계 (옛 link 손실 자동 복구 부재)를 패치.
6
+
7
+ ### Added
8
+
9
+ - **`leerness task relink [--apply] [--min-score 0.2]`** — `plan.md`의 milestone 텍스트와 `progress-tracker.md`의 task `request` 텍스트를 jaccard 토큰 유사도로 비교해 미연결 milestone을 가장 비슷한 row와 자동 매칭. default는 제안만 출력 (사용자가 명령 복사해 실행), `--apply`로 자동 적용. `--min-score`로 임계 조정 (기본 0.2).
10
+ - **`audit`이 미연결 milestone 발견 시 `leerness task relink` 안내 자동 출력**.
11
+
12
+ ### Migration
13
+
14
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
15
+
16
+ ## 1.9.5 — 2026-05-08
17
+
18
+ 1.9.4 운영 중 발견된 한계 2건 + 추가 디버그 사항을 패치합니다.
19
+
20
+ ### Fixed
21
+
22
+ - **F. `task fix-evidence --set` link 보존**: 기존 evidence의 `plan:M-XXXX` 링크를 새 텍스트에 자동으로 `(plan:M-XXXX)` 형태로 부착. `--no-preserve-link`로 끌 수 있음. 이전엔 링크가 사라져 audit이 milestone 미연결로 잡았음.
23
+ - **G. `impact` 동적 참조 (medium)**: `path.join`, `path.resolve`, `readFile`, `writeFile`, `fs.*`, `new URL` 등이 base 파일명을 인자로 받는 패턴을 별도 카테고리(medium)로 분리. default 출력에 strong + medium 모두 표시. site-cli의 `build.js`처럼 동적으로 컴포넌트를 읽는 빌더가 더 이상 weak로만 잡히지 않음.
24
+
25
+ ### Migration
26
+
27
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
28
+
3
29
  ## 1.9.4 — 2026-05-08
4
30
 
5
31
  1.9.3 운영 중 발견된 5개 한계점을 모두 패치합니다.
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.4';
9
+ const VERSION = '1.9.6';
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,67 @@ function taskDrop(root, id) {
679
679
  ok(`task dropped: ${id}`);
680
680
  }
681
681
 
682
+ // 1.9.6: 옛 link 손실 row를 plan.md milestone과 자동 매칭 제안/복구.
683
+ function _tokenizeForSim(s) {
684
+ // unicode letter/number만 보존 — \W는 ASCII 기준이라 한글이 분리되는 버그 회피
685
+ return new Set(
686
+ String(s || '').toLowerCase()
687
+ .split(/\s+/)
688
+ .map(t => t.replace(/[^\p{L}\p{N}_]+/gu, ''))
689
+ .filter(t => t.length >= 2)
690
+ );
691
+ }
692
+ function _jaccard(a, b) {
693
+ const inter = new Set([...a].filter(x => b.has(x))).size;
694
+ const uni = new Set([...a, ...b]).size;
695
+ return uni ? inter / uni : 0;
696
+ }
697
+
698
+ function taskRelink(root) {
699
+ root = absRoot(root);
700
+ const planText = exists(planPath(root)) ? read(planPath(root)) : '';
701
+ const milestones = [...planText.matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)]
702
+ .map(m => ({ id: m[1], text: m[2].trim() }));
703
+ const rows = readProgressRows(root);
704
+ const linkedM = new Set(rows.map(r => (r.evidence.match(/M-\d{4}/) || [])[0]).filter(Boolean));
705
+ const orphanM = milestones.filter(m => !linkedM.has(m.id));
706
+ if (!orphanM.length) return ok('미연결 milestone 없음');
707
+
708
+ const apply = has('--apply');
709
+ const minScore = parseFloat(arg('--min-score', '0.2'));
710
+ log(`# task relink — 미연결 milestone ${orphanM.length}개${apply ? ' (--apply: 자동 적용)' : ' (제안만, --apply로 적용)'}`);
711
+ const suggestions = [];
712
+ for (const m of orphanM) {
713
+ const milestoneTokens = _tokenizeForSim(m.text);
714
+ const candidates = rows
715
+ .map(r => ({ r, score: _jaccard(milestoneTokens, _tokenizeForSim(r.request)) }))
716
+ .filter(x => x.score >= minScore)
717
+ .sort((a, b) => b.score - a.score);
718
+ log(`\n${m.id}: ${m.text}`);
719
+ if (!candidates.length) {
720
+ log(` ⓘ 매칭 후보 없음 (score ≥ ${minScore})`);
721
+ log(` → 새 task: leerness task add "${m.text}" --status planned --evidence "plan:${m.id}"`);
722
+ continue;
723
+ }
724
+ const best = candidates[0];
725
+ const newEv = best.r.evidence.includes(`plan:${m.id}`) ? best.r.evidence : `${best.r.evidence} (plan:${m.id})`;
726
+ log(` ✓ 최선 후보: ${best.r.id} (score ${best.score.toFixed(2)}) — ${best.r.request}`);
727
+ log(` 현재 evidence: "${best.r.evidence}"`);
728
+ log(` 제안 evidence: "${newEv}"`);
729
+ log(` 수동: leerness task update ${best.r.id} --evidence "${newEv}"`);
730
+ if (candidates.length > 1) {
731
+ const next = candidates.slice(1, 3).map(c => `${c.r.id}(${c.score.toFixed(2)})`).join(', ');
732
+ log(` 다른 후보: ${next}`);
733
+ }
734
+ suggestions.push({ id: best.r.id, evidence: newEv });
735
+ }
736
+ if (apply && suggestions.length) {
737
+ for (const s of suggestions) upsertProgress(root, { id: s.id, evidence: s.evidence });
738
+ log('');
739
+ ok(`${suggestions.length}개 row 자동 relink 완료`);
740
+ }
741
+ }
742
+
682
743
  // 1.9.4 D: evidence가 placeholder인 done row를 일괄 점검.
683
744
  function taskFixEvidence(root) {
684
745
  root = absRoot(root);
@@ -694,8 +755,21 @@ function taskFixEvidence(root) {
694
755
  if (!candidates.length) return ok('갱신 후보 없음 (모든 done row가 검증 키워드 보유)');
695
756
  const setAll = arg('--set', null);
696
757
  if (setAll) {
697
- for (const r of candidates) upsertProgress(root, { id: r.id, evidence: setAll });
698
- ok(`${candidates.length}개 row의 evidence를 일괄 갱신`);
758
+ // 1.9.5 F: 기존 evidence의 plan:M-XXXX 링크를 텍스트에 자동 보존 (--no-preserve-link로 끄기)
759
+ const preserveLink = !has('--no-preserve-link');
760
+ let preserved = 0;
761
+ for (const r of candidates) {
762
+ let newEv = setAll;
763
+ if (preserveLink) {
764
+ const m = (r.evidence || '').match(/plan:M-\d{4}/);
765
+ if (m && !newEv.includes(m[0])) {
766
+ newEv = `${setAll} (${m[0]})`;
767
+ preserved++;
768
+ }
769
+ }
770
+ upsertProgress(root, { id: r.id, evidence: newEv });
771
+ }
772
+ ok(`${candidates.length}개 row의 evidence를 일괄 갱신${preserveLink ? ` (link 보존: ${preserved}건)` : ''}`);
699
773
  return;
700
774
  }
701
775
  log(`# task fix-evidence — ${candidates.length}개 후보`);
@@ -773,9 +847,17 @@ function audit(root) {
773
847
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
774
848
  const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
775
849
  const rows = readProgressRows(root);
776
- const linkedMs = new Set(rows.map(r => (r.evidence.match(/M-\d{4}/) || [])[0]).filter(Boolean));
850
+ // 1.9.6 수정: row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
851
+ const linkedMs = new Set(
852
+ rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4}/g), m => m[0]))
853
+ );
777
854
  const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
778
- if (missingFromProgress.length) { warnings++; warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`); }
855
+ if (missingFromProgress.length) {
856
+ warnings++;
857
+ warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`);
858
+ log(` → 자동 매칭 제안: leerness task relink`);
859
+ log(` → 자동 적용: leerness task relink --apply`);
860
+ }
779
861
  else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
780
862
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
781
863
  if (handoff.includes('Last generated: (자동)')) { warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)'); }
@@ -1146,43 +1228,58 @@ function impactCmd(root, target) {
1146
1228
  const base = path.basename(target);
1147
1229
  const noext = path.basename(target, path.extname(target));
1148
1230
  const targetRel = rel(root, abs);
1149
- // 1.9.4 A: strong (import-style, 확신도 높음) vs weak (단순 식별자, 가능성 있음) 구분.
1231
+ const eb = escapeRegex(base);
1232
+ // 1.9.5 G: strong (정적 import) / medium (동적 path 함수) / weak (식별자 등장) 3단계.
1150
1233
  const strongRe = new RegExp(
1151
1234
  `(?:` +
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)}` +
1235
+ `import\\s+[^;\\n]*?from\\s+['"][^'"]*${eb}` +
1236
+ `|require\\(\\s*['"][^'"]*${eb}` +
1237
+ `|@import\\s+['"][^'"]*${eb}` +
1238
+ `|href=["'][^"']*${eb}` +
1239
+ `|src=["'][^"']*${eb}` +
1240
+ `|url\\(\\s*['"]?[^'")]*${eb}` +
1241
+ `|include\\(\\s*['"][^'"]*${eb}` +
1242
+ `)`
1243
+ );
1244
+ // 동적 path 조합 / 파일 시스템 호출과 함께 base 파일명이 등장하는 경우.
1245
+ const mediumRe = new RegExp(
1246
+ `(?:` +
1247
+ `path\\.(?:join|resolve|relative|parse|format|normalize)\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1248
+ `|(?:readFile|writeFile|stat|access|open|createReadStream|createWriteStream|readFileSync|writeFileSync|statSync|accessSync|openSync)\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1249
+ `|fs\\.[a-zA-Z]+\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1250
+ `|new\\s+URL\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1159
1251
  `)`
1160
1252
  );
1161
- // word boundary 강화: cards 안의 card는 매치 안 함.
1162
1253
  const weakRe = new RegExp(`(?<![A-Za-z0-9_])${escapeRegex(noext)}(?![A-Za-z0-9_])`);
1163
- const high = []; const low = [];
1254
+ const high = []; const medium = []; const low = [];
1164
1255
  for (const f of walkCode(root)) {
1165
1256
  if (path.resolve(f) === path.resolve(abs)) continue;
1166
1257
  let text; try { text = read(f); } catch { continue; }
1167
1258
  if (strongRe.test(text)) high.push(rel(root, f));
1259
+ else if (mediumRe.test(text)) medium.push(rel(root, f));
1168
1260
  else if (weakRe.test(text)) low.push(rel(root, f));
1169
1261
  }
1170
1262
  log(`# impact: ${targetRel}`);
1171
1263
  const showAll = has('--all');
1172
- if (high.length === 0 && (low.length === 0 || !showAll)) ok('영향 범위 없음 (강한 참조 없음)');
1264
+ const totalEffective = high.length + medium.length;
1265
+ if (totalEffective === 0 && (low.length === 0 || !showAll)) ok('영향 범위 없음 (강한/중간 참조 없음)');
1173
1266
  else {
1174
1267
  if (high.length) {
1175
1268
  log(`강한 참조 ${high.length}개 (import/require/href/src/@import/url/include):`);
1176
1269
  high.forEach(d => log(' - ' + d));
1177
1270
  } else log('강한 참조: 없음');
1271
+ if (medium.length) {
1272
+ log(`\n중간 참조 ${medium.length}개 (path.join/readFile/fs 등 동적 path):`);
1273
+ medium.forEach(d => log(' ~ ' + d));
1274
+ }
1178
1275
  if (showAll && low.length) {
1179
- log(`\n약한 참조 ${low.length}개 (식별자 등장 — false positive 가능, 확인 필요):`);
1276
+ log(`\n약한 참조 ${low.length}개 (식별자 등장 — false positive 가능):`);
1180
1277
  low.forEach(d => log(' · ' + d));
1181
1278
  } else if (low.length && !showAll) {
1182
- log(`\n💡 약한 참조 ${low.length}개 (--all 로 표시, 정확도는 떨어짐)`);
1279
+ log(`\n💡 약한 참조 ${low.length}개 (--all 로 표시)`);
1183
1280
  }
1184
1281
  }
1185
- return { target: targetRel, high, low };
1282
+ return { target: targetRel, high, medium, low };
1186
1283
  }
1187
1284
 
1188
1285
  function reuseMapPath(root) { return path.join(root, '.harness/reuse-map.md'); }
@@ -1511,7 +1608,7 @@ function viewworkInstall(root) {
1511
1608
  }
1512
1609
 
1513
1610
  function help() {
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`);
1611
+ 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|relink [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`);
1515
1612
  }
1516
1613
 
1517
1614
  async function main() {
@@ -1575,6 +1672,7 @@ async function main() {
1575
1672
  if (sub==='update') return taskUpdate(root, args[2]);
1576
1673
  if (sub==='drop') return taskDrop(root, args[2]);
1577
1674
  if (sub==='fix-evidence') return taskFixEvidence(root);
1675
+ if (sub==='relink') return taskRelink(root);
1578
1676
  }
1579
1677
  return help();
1580
1678
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -229,14 +229,67 @@ total++;
229
229
  // home.html에 "Card"라는 식별자가 plain text로 들어가도록 (false positive 시드)
230
230
  fs.appendFileSync(path.join(tmp, 'src/pages/home.html'), '\n<!-- 카드 콘텐츠는 Cards 배열로 -->\n');
231
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
232
  const strongOK = /강한 참조 \d+개/.test(r.stdout);
235
233
  const weakHint = /약한 참조|영향 범위 없음/.test(r.stdout);
236
234
  console.log(strongOK && weakHint ? '✓ B(A) impact: strong/weak 구분 출력' : '✗ B(A) impact 구분 실패');
237
235
  if (!(strongOK && weakHint)) failed++;
238
236
  }
239
237
 
238
+ // 1.9.6 회귀: task relink — 인위적 link 손실 → 자동 매칭 제안 + --apply 자동 복구
239
+ total++;
240
+ {
241
+ // plan.md에 새 milestone 추가, progress엔 link 없는 비슷한 row 추가
242
+ const planPath_ = path.join(tmp, '.harness/plan.md');
243
+ fs.appendFileSync(planPath_,
244
+ `\n### M-9001. 캐시 헬퍼 모듈\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] 캐시 helper\n` +
245
+ `\n### M-9002. 인증 헬퍼 모듈\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] 인증 helper\n`);
246
+ // progress에 link 없는 비슷한 row 2개 추가
247
+ const trackerPath_ = path.join(tmp, '.harness/progress-tracker.md');
248
+ fs.appendFileSync(trackerPath_,
249
+ `| T-9001 | done | 캐시 helper 구현 | tests:5/5 (link lost) | 다음 단계 | 2026-05-08 |\n` +
250
+ `| T-9002 | done | 인증 helper 구현 | tests:8/8 (link lost) | 다음 단계 | 2026-05-08 |\n`);
251
+
252
+ // 제안 모드
253
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'task', 'relink', '--path', tmp], { encoding: 'utf8' });
254
+ const okSuggest = r1.status === 0 && /M-9001/.test(r1.stdout) && /M-9002/.test(r1.stdout) && /최선 후보/.test(r1.stdout);
255
+ console.log(okSuggest ? '✓ B(1.9.6) task relink 제안: 2개 매칭 발견' : `✗ B(1.9.6) 제안 실패\n${r1.stdout}`);
256
+ if (!okSuggest) failed++;
257
+ }
258
+ total++;
259
+ {
260
+ // --apply
261
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'task', 'relink', '--apply', '--path', tmp], { encoding: 'utf8' });
262
+ const tracker = fs.readFileSync(path.join(tmp, '.harness/progress-tracker.md'), 'utf8');
263
+ const okApply = r2.status === 0 && /M-9001/.test(tracker) && /M-9002/.test(tracker);
264
+ console.log(okApply ? '✓ B(1.9.6) task relink --apply: 자동 복구' : '✗ B(1.9.6) --apply 실패');
265
+ if (!okApply) failed++;
266
+ }
267
+ total++;
268
+ {
269
+ // audit이 task relink 안내를 출력하는지 (이번엔 link 복구 후라 미연결 milestone 없을 것)
270
+ // 일부러 새 milestone 추가 후 audit
271
+ fs.appendFileSync(path.join(tmp, '.harness/plan.md'),
272
+ `\n### M-9999. 매칭 후보 없는 milestone\nStatus: planned\n\nTasks:\n- [ ] x\n`);
273
+ const r3 = cp.spawnSync(process.execPath, [CLI, 'audit', tmp], { encoding: 'utf8' });
274
+ const ok = /milestones without progress entry/.test(r3.stdout) && /M-9999/.test(r3.stdout) && /leerness task relink/.test(r3.stdout);
275
+ console.log(ok ? '✓ B(1.9.6) audit이 task relink 안내 출력' : `✗ B(1.9.6) audit 안내 누락\n${r3.stdout}`);
276
+ if (!ok) failed++;
277
+ }
278
+
279
+ // 1.9.5 G 회귀: impact medium 카테고리 — 동적 path 패턴
280
+ total++;
281
+ {
282
+ // builder.js에서 path.join + readFileSync + Card.html을 동적 사용
283
+ fs.writeFileSync(path.join(tmp, 'src/builder.js'),
284
+ `const fs = require('fs'); const path = require('path');\n` +
285
+ `const tpl = fs.readFileSync(path.join(__dirname, 'components', 'Card.html'), 'utf8');\n` +
286
+ `module.exports = { tpl };\n`);
287
+ const r = cp.spawnSync(process.execPath, [CLI, 'impact', 'src/components/Card.html', '--path', tmp], { encoding: 'utf8' });
288
+ const ok = /중간 참조 \d+개/.test(r.stdout) && /src\/builder\.js/.test(r.stdout);
289
+ console.log(ok ? '✓ B(G) impact medium: builder.js (path.join + readFileSync) 검출' : `✗ B(G) medium 검출 실패\n${r.stdout}`);
290
+ if (!ok) failed++;
291
+ }
292
+
240
293
  // 1.9.4 회귀: --fail-on-violation cross-platform 종료
241
294
  total++;
242
295
  {
@@ -281,13 +334,43 @@ total++;
281
334
  // 1.9.4 회귀: --set 일괄 갱신
282
335
  total++;
283
336
  {
337
+ // T-0001 evidence를 plan:M-0002 링크 포함한 placeholder로 (1.9.5 link 보존 검증용 시드)
338
+ const trackerPath2 = path.join(tmp, '.harness/progress-tracker.md');
339
+ let cur = fs.readFileSync(trackerPath2, 'utf8');
340
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | plan:M-0002 | next | 2026-05-08 |');
341
+ fs.writeFileSync(trackerPath2, cur);
284
342
  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');
343
+ const tracker = fs.readFileSync(trackerPath2, 'utf8');
286
344
  const ok = r.status === 0 && /npm test 통과 \(e2e\)/.test(tracker);
287
345
  console.log(ok ? '✓ B(D2) task fix-evidence --set: 일괄 갱신' : '✗ B(D2) 일괄 갱신 실패');
288
346
  if (!ok) failed++;
289
347
  }
290
348
 
349
+ // 1.9.5 F 회귀: --set 시 plan:M-XXXX 링크 자동 보존
350
+ total++;
351
+ {
352
+ const tracker = fs.readFileSync(path.join(tmp, '.harness/progress-tracker.md'), 'utf8');
353
+ const ok = /npm test 통과 \(e2e\) \(plan:M-0002\)/.test(tracker);
354
+ console.log(ok ? '✓ B(F) fix-evidence --set: link 자동 보존' : `✗ B(F) link 손실\n${tracker}`);
355
+ if (!ok) failed++;
356
+ }
357
+
358
+ // 1.9.5 F neg: --no-preserve-link
359
+ total++;
360
+ {
361
+ // T-0002 같은 row를 placeholder로 시드
362
+ const trackerPath3 = path.join(tmp, '.harness/progress-tracker.md');
363
+ let cur = fs.readFileSync(trackerPath3, 'utf8');
364
+ // T-0001을 다시 plan:M-0099로 교체
365
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | plan:M-0099 | next | 2026-05-08 |');
366
+ fs.writeFileSync(trackerPath3, cur);
367
+ cp.spawnSync(process.execPath, [CLI, 'task', 'fix-evidence', '--set', 'npm test only', '--no-preserve-link', '--path', tmp], { encoding: 'utf8' });
368
+ const tracker = fs.readFileSync(trackerPath3, 'utf8');
369
+ const ok = /\| T-0001 \| done \| mile A \| npm test only \|/.test(tracker) && !/M-0099/.test(tracker);
370
+ console.log(ok ? '✓ B(F neg) fix-evidence --no-preserve-link: 링크 제거' : `✗ B(F neg) 동작 이상`);
371
+ if (!ok) failed++;
372
+ }
373
+
291
374
  // 1.9.4 회귀: .leerness-skip-dirs 적용
292
375
  total++;
293
376
  {