leerness 1.9.3 → 1.9.5

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.5 — 2026-05-08
4
+
5
+ 1.9.4 운영 중 발견된 한계 2건 + 추가 디버그 사항을 패치합니다.
6
+
7
+ ### Fixed
8
+
9
+ - **F. `task fix-evidence --set` link 보존**: 기존 evidence의 `plan:M-XXXX` 링크를 새 텍스트에 자동으로 `(plan:M-XXXX)` 형태로 부착. `--no-preserve-link`로 끌 수 있음. 이전엔 링크가 사라져 audit이 milestone 미연결로 잡았음.
10
+ - **G. `impact` 동적 참조 (medium)**: `path.join`, `path.resolve`, `readFile`, `writeFile`, `fs.*`, `new URL` 등이 base 파일명을 인자로 받는 패턴을 별도 카테고리(medium)로 분리. default 출력에 strong + medium 모두 표시. site-cli의 `build.js`처럼 동적으로 컴포넌트를 읽는 빌더가 더 이상 weak로만 잡히지 않음.
11
+
12
+ ### Migration
13
+
14
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
15
+
16
+ ## 1.9.4 — 2026-05-08
17
+
18
+ 1.9.3 운영 중 발견된 5개 한계점을 모두 패치합니다.
19
+
20
+ ### Fixed
21
+
22
+ - **A. impact 정확도**: 강한 참조(`import / require / @import / href / src / url / include`)와 약한 참조(식별자 등장)를 분리해 default는 강한 참조만 출력. word boundary 추가로 `cards` 안의 `card`가 false positive로 잡히던 문제 해결. `--all`로 약한 참조까지 표시.
23
+ - **B. cross-platform 종료 코드**: main이 끝난 뒤 `process.exit(process.exitCode)`을 명시. 셸 wrapper나 npx 파이프라인에서 `$?`이 0으로 보이던 문제 해결. `ui consistency --fail-on-violation`은 `--strict-exit`로 즉시 `process.exit(1)`도 가능.
24
+ - **C. lazy detect string literal 휴리스틱**: 매치 위치가 `'…'`/`"…"`/`` `…` `` 안이면 카운트에서 제외. leerness CLI 자기 자신(bin/harness.js)도 자동 skip. 메인 디렉토리에서 30개 잡히던 false positive 사실상 0.
25
+
26
+ ### Added
27
+
28
+ - **D. `leerness task fix-evidence`** — `done` 상태이면서 evidence가 비어있거나 `user-request` / `plan:M-XXXX` 단독인 row를 일괄 점검. `--set "<텍스트>"`로 일괄 갱신, 또는 row별 `task update` 명령을 출력해 가이드.
29
+ - **E. `.leerness-skip-dirs` 파일** — 프로젝트 루트에 두면 추가 skip 디렉토리(예: `_apps/`, `leerness-pkg/`)가 모든 walk에서 적용됨. 1줄당 1개 디렉토리, `#` 주석 지원. 기본 skip 셋에도 `out`, `tmp`, `temp`, `.svelte-kit`, `.parcel-cache` 추가.
30
+
31
+ ### Migration
32
+
33
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
34
+
3
35
  ## 1.9.3 — 2026-05-08
4
36
 
5
37
  이번 릴리스는 "이전 작업과 새 작업의 인과관계·재귀 안내·디자인 일관성"을 자동화합니다.
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.5';
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,50 @@ 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
+ // 1.9.5 F: 기존 evidence의 plan:M-XXXX 링크를 새 텍스트에 자동 보존 (--no-preserve-link로 끄기)
698
+ const preserveLink = !has('--no-preserve-link');
699
+ let preserved = 0;
700
+ for (const r of candidates) {
701
+ let newEv = setAll;
702
+ if (preserveLink) {
703
+ const m = (r.evidence || '').match(/plan:M-\d{4}/);
704
+ if (m && !newEv.includes(m[0])) {
705
+ newEv = `${setAll} (${m[0]})`;
706
+ preserved++;
707
+ }
708
+ }
709
+ upsertProgress(root, { id: r.id, evidence: newEv });
710
+ }
711
+ ok(`${candidates.length}개 row의 evidence를 일괄 갱신${preserveLink ? ` (link 보존: ${preserved}건)` : ''}`);
712
+ return;
713
+ }
714
+ log(`# task fix-evidence — ${candidates.length}개 후보`);
715
+ log(`아래 row들은 evidence가 검증 키워드(테스트/명령/결과)를 포함하지 않습니다.`);
716
+ log(`각각 다음 명령으로 갱신하거나, --set "<공통 텍스트>"로 일괄 갱신하세요.\n`);
717
+ for (const r of candidates) {
718
+ log(`leerness task update ${r.id} --evidence "검증 결과 (e.g., npm test 통과)"`);
719
+ log(` 요청: ${r.request}`);
720
+ log(` 현재 evidence: "${r.evidence || ''}"`);
721
+ log('');
722
+ }
723
+ if (has('--fail-on-candidates')) process.exit(1);
724
+ }
725
+
682
726
  function route(name) {
683
727
  const r = routes[name];
684
728
  if (!r) { fail('Unknown route'); log('Available: ' + Object.keys(routes).join(', ')); return; }
@@ -771,15 +815,26 @@ const SECRET_PATTERNS = [
771
815
  { name: 'Generic private key', re: /-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----/g },
772
816
  { name: 'Hardcoded password assignment', re: /\b(?:password|passwd|pwd|secret|api_key|apikey)\s*[:=]\s*["'][^"'\s]{6,}["']/gi },
773
817
  ];
774
- const SCAN_SKIP_DIRS = new Set(['.git','node_modules','.harness/archive','.viewwork','dist','build','.next','.turbo','.cache','coverage','_pkg-source']);
818
+ 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']);
819
+ // 1.9.4 E: .leerness-skip-dirs 파일에서 추가 skip 디렉토리 읽기
820
+ function getExtraSkipDirs(root) {
821
+ const f = path.join(absRoot(root || '.'), '.leerness-skip-dirs');
822
+ if (!exists(f)) return [];
823
+ return read(f).split('\n').map(s => s.trim().replace(/\/+$/, '')).filter(s => s && !s.startsWith('#'));
824
+ }
825
+ function isSkippedRel(rel, extras = []) {
826
+ const all = [...SCAN_SKIP_DIRS, ...extras];
827
+ return all.some(d => rel === d || rel.startsWith(d + '/'));
828
+ }
775
829
  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) {
830
+ function* walk(root, base = root, depth = 0, extras = null) {
777
831
  if (depth > 12) return;
832
+ if (extras === null) extras = getExtraSkipDirs(root);
778
833
  for (const e of fs.readdirSync(base, { withFileTypes: true })) {
779
834
  const p = path.join(base, e.name);
780
835
  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);
836
+ if (isSkippedRel(r, extras)) continue;
837
+ if (e.isDirectory()) yield* walk(root, p, depth + 1, extras);
783
838
  else yield p;
784
839
  }
785
840
  }
@@ -871,12 +926,32 @@ function lazyDetect(root) {
871
926
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
872
927
  const hasTestRun = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress)\b/i.test(ev);
873
928
  if (!hasTestRun) { issues++; warn('review-evidence.md has no recorded test/typecheck/lint run'); }
929
+ // 1.9.4 C: TODO/FIXME가 string literal 안에 있으면 제외 (정규식 패턴 자체 등 false positive).
930
+ function isInsideQuote(line, idx) {
931
+ const pre = line.slice(0, idx);
932
+ const sq = (pre.match(/(?<!\\)'/g) || []).length;
933
+ const dq = (pre.match(/(?<!\\)"/g) || []).length;
934
+ const bq = (pre.match(/(?<!\\)`/g) || []).length;
935
+ return (sq % 2 === 1) || (dq % 2 === 1) || (bq % 2 === 1);
936
+ }
874
937
  let todoCount = 0;
938
+ const cliSelf = path.resolve(__filename);
875
939
  for (const file of walk(root)) {
876
940
  const ext = path.extname(file).toLowerCase();
877
- if (!SCAN_TEXT_EXT.has(ext) || file.includes('.harness') || file.includes('harness.js')) continue;
941
+ if (!SCAN_TEXT_EXT.has(ext)) continue;
942
+ if (file.includes('.harness')) continue;
943
+ if (path.resolve(file) === cliSelf) continue;
944
+ if (/[\\/]bin[\\/]harness\.js$/.test(file)) continue;
878
945
  let text; try { text = read(file); } catch { continue; }
879
- todoCount += (text.match(/\bTODO\b|\bFIXME\b|\bXXX\b/g) || []).length;
946
+ const lines = text.split('\n');
947
+ const tre = /\bTODO\b|\bFIXME\b|\bXXX\b/g;
948
+ for (const line of lines) {
949
+ tre.lastIndex = 0;
950
+ let m;
951
+ while ((m = tre.exec(line))) {
952
+ if (!isInsideQuote(line, m.index)) todoCount++;
953
+ }
954
+ }
880
955
  }
881
956
  if (todoCount > 0) {
882
957
  const hasTodoTask = rows.some(r => /TODO|FIXME|XXX/.test(r.request) || /TODO|FIXME|XXX/i.test(r.evidence));
@@ -1064,13 +1139,14 @@ function gate(root) {
1064
1139
 
1065
1140
  // ===== 1.9.3: Causal / reuse / consistency =====
1066
1141
  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) {
1142
+ function* walkCode(root, base = root, depth = 0, extras = null) {
1068
1143
  if (depth > 12) return;
1144
+ if (extras === null) extras = getExtraSkipDirs(root);
1069
1145
  for (const e of fs.readdirSync(base, { withFileTypes: true })) {
1070
1146
  const p = path.join(base, e.name);
1071
1147
  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);
1148
+ if (isSkippedRel(r, extras)) continue;
1149
+ if (e.isDirectory()) yield* walkCode(root, p, depth + 1, extras);
1074
1150
  else if (CODE_EXT.has(path.extname(p).toLowerCase())) yield p;
1075
1151
  }
1076
1152
  }
@@ -1082,25 +1158,59 @@ function impactCmd(root, target) {
1082
1158
  const abs = path.isAbsolute(target) ? target : path.resolve(root, target);
1083
1159
  const base = path.basename(target);
1084
1160
  const noext = path.basename(target, path.extname(target));
1085
- const direct = [];
1086
1161
  const targetRel = rel(root, abs);
1162
+ const eb = escapeRegex(base);
1163
+ // 1.9.5 G: strong (정적 import) / medium (동적 path 함수) / weak (식별자 등장) 3단계.
1164
+ const strongRe = new RegExp(
1165
+ `(?:` +
1166
+ `import\\s+[^;\\n]*?from\\s+['"][^'"]*${eb}` +
1167
+ `|require\\(\\s*['"][^'"]*${eb}` +
1168
+ `|@import\\s+['"][^'"]*${eb}` +
1169
+ `|href=["'][^"']*${eb}` +
1170
+ `|src=["'][^"']*${eb}` +
1171
+ `|url\\(\\s*['"]?[^'")]*${eb}` +
1172
+ `|include\\(\\s*['"][^'"]*${eb}` +
1173
+ `)`
1174
+ );
1175
+ // 동적 path 조합 / 파일 시스템 호출과 함께 base 파일명이 등장하는 경우.
1176
+ const mediumRe = new RegExp(
1177
+ `(?:` +
1178
+ `path\\.(?:join|resolve|relative|parse|format|normalize)\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1179
+ `|(?:readFile|writeFile|stat|access|open|createReadStream|createWriteStream|readFileSync|writeFileSync|statSync|accessSync|openSync)\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1180
+ `|fs\\.[a-zA-Z]+\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1181
+ `|new\\s+URL\\s*\\([^)]*['"][^'"]*${eb}[^'"]*['"]` +
1182
+ `)`
1183
+ );
1184
+ const weakRe = new RegExp(`(?<![A-Za-z0-9_])${escapeRegex(noext)}(?![A-Za-z0-9_])`);
1185
+ const high = []; const medium = []; const low = [];
1087
1186
  for (const f of walkCode(root)) {
1088
1187
  if (path.resolve(f) === path.resolve(abs)) continue;
1089
1188
  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));
1189
+ if (strongRe.test(text)) high.push(rel(root, f));
1190
+ else if (mediumRe.test(text)) medium.push(rel(root, f));
1191
+ else if (weakRe.test(text)) low.push(rel(root, f));
1096
1192
  }
1097
1193
  log(`# impact: ${targetRel}`);
1098
- if (direct.length === 0) ok('영향 범위 없음 (참조하는 파일 없음)');
1194
+ const showAll = has('--all');
1195
+ const totalEffective = high.length + medium.length;
1196
+ if (totalEffective === 0 && (low.length === 0 || !showAll)) ok('영향 범위 없음 (강한/중간 참조 없음)');
1099
1197
  else {
1100
- log(`참조하는 파일 ${direct.length}개:`);
1101
- direct.forEach(d => log('- ' + d));
1198
+ if (high.length) {
1199
+ log(`강한 참조 ${high.length}개 (import/require/href/src/@import/url/include):`);
1200
+ high.forEach(d => log(' - ' + d));
1201
+ } else log('강한 참조: 없음');
1202
+ if (medium.length) {
1203
+ log(`\n중간 참조 ${medium.length}개 (path.join/readFile/fs 등 동적 path):`);
1204
+ medium.forEach(d => log(' ~ ' + d));
1205
+ }
1206
+ if (showAll && low.length) {
1207
+ log(`\n약한 참조 ${low.length}개 (식별자 등장 — false positive 가능):`);
1208
+ low.forEach(d => log(' · ' + d));
1209
+ } else if (low.length && !showAll) {
1210
+ log(`\n💡 약한 참조 ${low.length}개 (--all 로 표시)`);
1211
+ }
1102
1212
  }
1103
- return { target: targetRel, direct };
1213
+ return { target: targetRel, high, medium, low };
1104
1214
  }
1105
1215
 
1106
1216
  function reuseMapPath(root) { return path.join(root, '.harness/reuse-map.md'); }
@@ -1206,7 +1316,8 @@ function uiConsistency(root) {
1206
1316
  warn(`토큰 외 값 ${findings.length}개:`);
1207
1317
  for (const f of findings.slice(0, 30)) log(` ${f.file}:${f.line} ${f.value} (${f.type})`);
1208
1318
  if (findings.length > 30) log(` ... +${findings.length - 30}개`);
1209
- if (has('--fail-on-violation')) process.exitCode = 1;
1319
+ // 1.9.4 B: cross-platform 종료 코드 명시
1320
+ if (has('--fail-on-violation')) { process.exitCode = 1; if (has('--strict-exit')) process.exit(1); }
1210
1321
  }
1211
1322
 
1212
1323
  function graphCmd(root) {
@@ -1428,7 +1539,7 @@ function viewworkInstall(root) {
1428
1539
  }
1429
1540
 
1430
1541
  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`);
1542
+ 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
1543
  }
1433
1544
 
1434
1545
  async function main() {
@@ -1491,8 +1602,12 @@ async function main() {
1491
1602
  if (sub==='add') return taskAdd(root, args.slice(2).join(' ') || '새 작업');
1492
1603
  if (sub==='update') return taskUpdate(root, args[2]);
1493
1604
  if (sub==='drop') return taskDrop(root, args[2]);
1605
+ if (sub==='fix-evidence') return taskFixEvidence(root);
1494
1606
  }
1495
1607
  return help();
1496
1608
  }
1497
1609
 
1498
- main().catch(err => { fail(err && err.message ? err.message : String(err)); process.exitCode = 1; });
1610
+ // 1.9.4 B: main 종료 exitCode를 명시적으로 process.exit으로 강제 (셸/wrapper 무시).
1611
+ main()
1612
+ .then(() => { if (process.exitCode && process.exitCode !== 0) process.exit(process.exitCode); })
1613
+ .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.5",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -223,6 +223,125 @@ 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
+ const strongOK = /강한 참조 \d+개/.test(r.stdout);
233
+ const weakHint = /약한 참조|영향 범위 없음/.test(r.stdout);
234
+ console.log(strongOK && weakHint ? '✓ B(A) impact: strong/weak 구분 출력' : '✗ B(A) impact 구분 실패');
235
+ if (!(strongOK && weakHint)) failed++;
236
+ }
237
+
238
+ // 1.9.5 G 회귀: impact medium 카테고리 — 동적 path 패턴
239
+ total++;
240
+ {
241
+ // builder.js에서 path.join + readFileSync + Card.html을 동적 사용
242
+ fs.writeFileSync(path.join(tmp, 'src/builder.js'),
243
+ `const fs = require('fs'); const path = require('path');\n` +
244
+ `const tpl = fs.readFileSync(path.join(__dirname, 'components', 'Card.html'), 'utf8');\n` +
245
+ `module.exports = { tpl };\n`);
246
+ const r = cp.spawnSync(process.execPath, [CLI, 'impact', 'src/components/Card.html', '--path', tmp], { encoding: 'utf8' });
247
+ const ok = /중간 참조 \d+개/.test(r.stdout) && /src\/builder\.js/.test(r.stdout);
248
+ console.log(ok ? '✓ B(G) impact medium: builder.js (path.join + readFileSync) 검출' : `✗ B(G) medium 검출 실패\n${r.stdout}`);
249
+ if (!ok) failed++;
250
+ }
251
+
252
+ // 1.9.4 회귀: --fail-on-violation cross-platform 종료
253
+ total++;
254
+ {
255
+ fs.appendFileSync(path.join(tmp, 'src/pages/home.html'), '\n<style>.x{color:#cafe00;}</style>\n');
256
+ const r = cp.spawnSync(process.execPath, [CLI, 'ui', 'consistency', tmp, '--fail-on-violation'], { encoding: 'utf8' });
257
+ const ok = r.status === 1;
258
+ console.log(ok ? '✓ B(B) ui consistency --fail-on-violation: exit=1' : `✗ B(B) exit=${r.status}`);
259
+ if (!ok) failed++;
260
+ }
261
+
262
+ // 1.9.4 회귀: lazy detect string literal 무시
263
+ total++;
264
+ {
265
+ // false positive 시드: TODO 단어가 string literal 안에 있는 코드
266
+ fs.writeFileSync(path.join(tmp, 'src/regex-helper.js'), `module.exports = { TODO_RE: /\\bTODO\\b/g, label: 'TODO list' };\n`);
267
+ // 다른 한편 진짜 TODO 주석
268
+ fs.writeFileSync(path.join(tmp, 'src/real-todo.js'), `// TODO: 실제 미완료 작업\nconst x = 1;\n`);
269
+ const r = cp.spawnSync(process.execPath, [CLI, 'lazy', 'detect', tmp], { encoding: 'utf8' });
270
+ // string literal 안의 TODO는 무시되고, 주석 안의 진짜 TODO만 카운트되어야 함
271
+ const todosLine = (r.stdout.match(/code has (\d+) TODO/) || [0,'-'])[1];
272
+ const ok = todosLine === '1' || todosLine === '-' || /lazy detect passed/.test(r.stdout);
273
+ console.log(ok ? `✓ B(C) lazy detect: string literal 무시 (count=${todosLine})` : `✗ B(C) count=${todosLine}`);
274
+ if (!ok) failed++;
275
+ fs.unlinkSync(path.join(tmp, 'src/regex-helper.js'));
276
+ fs.unlinkSync(path.join(tmp, 'src/real-todo.js'));
277
+ }
278
+
279
+ // 1.9.4 회귀: task fix-evidence 표시
280
+ total++;
281
+ {
282
+ // T-0001 evidence를 placeholder로 (test before가 'review-evidence:e2e' 였음 → 'user-request'로 바꿈)
283
+ const trackerPath = path.join(tmp, '.harness/progress-tracker.md');
284
+ let cur = fs.readFileSync(trackerPath, 'utf8');
285
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | user-request | next | 2026-05-08 |');
286
+ fs.writeFileSync(trackerPath, cur);
287
+ const r = cp.spawnSync(process.execPath, [CLI, 'task', 'fix-evidence', '--path', tmp], { encoding: 'utf8' });
288
+ const ok = r.status === 0 && /T-0001/.test(r.stdout) && /후보/.test(r.stdout);
289
+ console.log(ok ? '✓ B(D) task fix-evidence: 후보 표시' : '✗ B(D) 후보 표시 실패');
290
+ if (!ok) { failed++; console.log(r.stdout); }
291
+ }
292
+
293
+ // 1.9.4 회귀: --set 일괄 갱신
294
+ total++;
295
+ {
296
+ // T-0001 evidence를 plan:M-0002 링크 포함한 placeholder로 (1.9.5 link 보존 검증용 시드)
297
+ const trackerPath2 = path.join(tmp, '.harness/progress-tracker.md');
298
+ let cur = fs.readFileSync(trackerPath2, 'utf8');
299
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | plan:M-0002 | next | 2026-05-08 |');
300
+ fs.writeFileSync(trackerPath2, cur);
301
+ const r = cp.spawnSync(process.execPath, [CLI, 'task', 'fix-evidence', '--set', 'npm test 통과 (e2e)', '--path', tmp], { encoding: 'utf8' });
302
+ const tracker = fs.readFileSync(trackerPath2, 'utf8');
303
+ const ok = r.status === 0 && /npm test 통과 \(e2e\)/.test(tracker);
304
+ console.log(ok ? '✓ B(D2) task fix-evidence --set: 일괄 갱신' : '✗ B(D2) 일괄 갱신 실패');
305
+ if (!ok) failed++;
306
+ }
307
+
308
+ // 1.9.5 F 회귀: --set 시 plan:M-XXXX 링크 자동 보존
309
+ total++;
310
+ {
311
+ const tracker = fs.readFileSync(path.join(tmp, '.harness/progress-tracker.md'), 'utf8');
312
+ const ok = /npm test 통과 \(e2e\) \(plan:M-0002\)/.test(tracker);
313
+ console.log(ok ? '✓ B(F) fix-evidence --set: link 자동 보존' : `✗ B(F) link 손실\n${tracker}`);
314
+ if (!ok) failed++;
315
+ }
316
+
317
+ // 1.9.5 F neg: --no-preserve-link
318
+ total++;
319
+ {
320
+ // T-0002 같은 row를 placeholder로 시드
321
+ const trackerPath3 = path.join(tmp, '.harness/progress-tracker.md');
322
+ let cur = fs.readFileSync(trackerPath3, 'utf8');
323
+ // T-0001을 다시 plan:M-0099로 교체
324
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | plan:M-0099 | next | 2026-05-08 |');
325
+ fs.writeFileSync(trackerPath3, cur);
326
+ cp.spawnSync(process.execPath, [CLI, 'task', 'fix-evidence', '--set', 'npm test only', '--no-preserve-link', '--path', tmp], { encoding: 'utf8' });
327
+ const tracker = fs.readFileSync(trackerPath3, 'utf8');
328
+ const ok = /\| T-0001 \| done \| mile A \| npm test only \|/.test(tracker) && !/M-0099/.test(tracker);
329
+ console.log(ok ? '✓ B(F neg) fix-evidence --no-preserve-link: 링크 제거' : `✗ B(F neg) 동작 이상`);
330
+ if (!ok) failed++;
331
+ }
332
+
333
+ // 1.9.4 회귀: .leerness-skip-dirs 적용
334
+ total++;
335
+ {
336
+ fs.mkdirSync(path.join(tmp, '_devspace'), { recursive: true });
337
+ fs.writeFileSync(path.join(tmp, '_devspace/secret-config.js'), `const k = "ghp_${'a'.repeat(36)}";\n`);
338
+ fs.writeFileSync(path.join(tmp, '.leerness-skip-dirs'), '_devspace/\n# 주석은 무시\n');
339
+ const r = cp.spawnSync(process.execPath, [CLI, 'scan', 'secrets', tmp], { encoding: 'utf8' });
340
+ const ok = r.status === 0 && /no obvious secret patterns/.test(r.stdout);
341
+ console.log(ok ? '✓ B(E) .leerness-skip-dirs: _devspace 자동 skip' : `✗ B(E) skip 실패\n${r.stdout}`);
342
+ if (!ok) failed++;
343
+ }
344
+
226
345
  run('gate (all checks)', ['gate', tmp]);
227
346
 
228
347
  run('self check (= update --check)', ['self', 'check', tmp], { });