leerness 1.9.420 → 1.9.421

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.421 — 2026-06-07 — 무거움 점진 해소 2: audit → lib/audit.js 모듈화 (UR-0025/UR-0125)
4
+
5
+ **🪶 `bin/leerness.js` 무거움 점진 해소 2단계 — `audit` 핸들러(310줄)를 lib/ 로 DI 분리.**
6
+
7
+ ### 배경
8
+ 1.9.420(review-request)에 이어 두 번째 큰 핸들러 추출. footprint 측정으로 audit(중심 명령이나 의존 추적 가능) 선정. **테스트주도 추출**: 추출 직후 audit text/--json 실행으로 누락 deps 즉시 검증(0건).
9
+
10
+ ### 변경
11
+ - `lib/audit.js` 신설(322줄): `audit(root, opts, deps)` — io는 `./io`, SECRET_PATTERNS는 `./catalogs`, cp/path 빌트인, harness 고유 의존 11종(VERSION·arg·has·planPath·readProgressRows·currentStatePath·handoffPath·envDiff·_readFeatureGraph·_matchAPISkills·_listAPISkills) **DI 주입**.
12
+ - `bin/leerness.js`: 310줄 → **3줄 thin wrapper**. **20,903 → 20,617줄(−286)**.
13
+ - 동작/출력 무변경(meta/design/reuse/CVE/api-skill 감사 + --fix + --json 동일).
14
+
15
+ ### 검증 (회귀 0)
16
+ - **selftest 166→167 PASS** (모듈 존재 + DI 위임 + 본문 이동(not_initialized finding kind) + healthy/findings 동작).
17
+ - **E2E 420→421 PASS** (audit는 gate/session-close/audit 케이스에서 핵심 검증).
18
+
19
+ ### 누적 효과 (UR-0125)
20
+ 2회 추출로 bin **21,177 → 20,617줄(−560, 2.6%)**. 다음 후보: driftCheck(~13) → healthCmd(~24) → handoff(1434).
21
+
3
22
  ## 1.9.420 — 2026-06-07 — 무거움 점진 해소: reviewRequestCmd → lib/review-request.js 모듈화 (UR-0025/UR-0125)
4
23
 
5
24
  **🪶 `bin/leerness.js`(21k줄/1.3MB) 무거움 점진 해소 — `review-request` 핸들러(277줄)를 lib/ 로 DI 분리.**
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **AI 코딩 에이전트의 거짓 완료·중복·망각·충돌을 막아주는 검수·기억·협업 CLI 하네스.**
4
4
  > **A CLI harness that stops AI coding agents from faking completion, duplicating work, forgetting context, and colliding.**
5
5
 
6
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.420-green)]() [![tests](https://img.shields.io/badge/e2e-357%2F357-success)]() [![selftest](https://img.shields.io/badge/selftest-166-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-85-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.421-green)]() [![tests](https://img.shields.io/badge/e2e-357%2F357-success)]() [![selftest](https://img.shields.io/badge/selftest-167-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-85-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
7
7
 
8
8
  ```
9
9
  ╔══════════════════════════════════════════════════════════════╗
@@ -471,7 +471,7 @@ MIT — © leerness contributors
471
471
  <!-- leerness:project-readme:start -->
472
472
  ## Leerness Project Harness
473
473
 
474
- 이 프로젝트는 Leerness v1.9.420 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
474
+ 이 프로젝트는 Leerness v1.9.421 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
475
475
 
476
476
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
477
477
 
@@ -525,7 +525,7 @@ leerness memory restore decision <date|title>
525
525
 
526
526
  ### MCP server (외부 AI 통합)
527
527
 
528
- Leerness v1.9.420는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
528
+ Leerness v1.9.421는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
529
529
 
530
530
  ```jsonc
531
531
  // 카테고리별
@@ -546,7 +546,7 @@ Leerness v1.9.420는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
546
546
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
547
547
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
548
548
 
549
- 현재 누적: **70 라운드 (1.9.40 → 1.9.420)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
549
+ 현재 누적: **70 라운드 (1.9.40 → 1.9.421)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
550
550
 
551
551
  ### 성능 가이드 (1.9.140 측정)
552
552
 
@@ -584,6 +584,6 @@ leerness release pack --close --auto-main-push
584
584
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
585
585
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
586
586
 
587
- Last synced by Leerness v1.9.420: 2026-06-07
587
+ Last synced by Leerness v1.9.421: 2026-06-07
588
588
  <!-- leerness:project-readme:end -->
589
589
 
package/bin/leerness.js CHANGED
@@ -31,7 +31,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
31
31
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
32
32
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 (MERGE_OVERWRITE_FILES/MINIMAL_SKIP_KEYS 포함)
33
33
 
34
- const VERSION = '1.9.420';
34
+ const VERSION = '1.9.421';
35
35
 
36
36
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
37
37
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -3073,6 +3073,26 @@ function _selfTestCases() {
3073
3073
  } },
3074
3074
  { name: '9라운드 (UR-0119/0120): team review(메인 검수) — _composeTeamPlan reviewStep + handoff 검수필요 + team add 와이어 (1.9.414)', run: () => { const m = require('../lib/pure-utils'); const on = m._composeTeamPlan({ id: 't', members: ['a', 'b'], personas: ['security'] }, '점검'); const off = m._composeTeamPlan({ id: 't', members: ['a'], review: false }, '점검'); const planOk = on.review === true && !!on.reviewStep && on.reviewStep.suggestedCommand.includes('verify-claim') && off.review === false && !off.reviewStep; const rem = m._teamHandoffReminders([{ id: 'r', schedule: 'every-session', status: 'active', members: ['a'], review: true }]); const remOk = rem.length === 1 && rem[0].includes('검수필요'); const teamSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'team.js')); const wired = teamSrc.includes("review: !has('--no-review')") && teamSrc.includes('메인 검수 (필수)'); return planOk && remOk && wired; } },
3075
3075
  { name: '파일명 변경 (UR-0126): bin 파일=leerness.js + package.json bin/main 일치 (1.9.419)', run: () => { const okName = path.basename(__filename) === 'leerness.js'; let pkg; try { pkg = require('../package.json'); } catch { return false; } const okBin = pkg && pkg.bin && pkg.bin.leerness === 'bin/leerness.js' && pkg.main === 'bin/leerness.js'; return okName && okBin; } },
3076
+ { name: 'UR-0025 큰핸들러 모듈화 6번째: audit → lib/audit.js + DI 위임 + 동작 (1.9.421)', run: () => {
3077
+ const m = require('../lib/audit');
3078
+ const expOk = typeof m.audit === 'function';
3079
+ const src = read(__filename);
3080
+ const delegated = src.includes("require('../lib/audit')") && src.includes('_audit.audit(root, opts,');
3081
+ const modSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'audit.js'));
3082
+ const bodyMarker = 'not_' + 'initialized'; // audit 본문 고유 finding kind(split-literal 자기참조 회피)
3083
+ const movedToLib = modSrc.includes("require('./io')") && modSrc.includes("require('./catalogs')") && modSrc.includes(bodyMarker) && !src.includes(bodyMarker);
3084
+ let behavOk = false;
3085
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_audit_'));
3086
+ const _w = process.stdout.write; let out = '';
3087
+ try {
3088
+ fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
3089
+ fs.writeFileSync(path.join(tmp, 'AGENTS.md'), '# x');
3090
+ process.stdout.write = s => { out += s; return true; };
3091
+ m.audit(tmp, { json: true }, { VERSION, arg: (k, d) => d, has: f => f === '--json', planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills });
3092
+ } catch (e) { out = 'ERR:' + e.message; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
3093
+ try { const j = JSON.parse(out); behavOk = typeof j.healthy === 'boolean' && Array.isArray(j.findings); } catch {}
3094
+ return expOk && delegated && movedToLib && behavOk;
3095
+ } },
3076
3096
  { name: 'UR-0025 큰핸들러 모듈화 5번째: reviewRequestCmd → lib/review-request.js + DI 위임 + 동작 (1.9.420)', run: () => {
3077
3097
  const m = require('../lib/review-request');
3078
3098
  const expOk = typeof m.reviewRequestCmd === 'function';
@@ -6851,316 +6871,9 @@ function debug(root) {
6851
6871
  if (failures) process.exitCode = 1;
6852
6872
  }
6853
6873
 
6854
- function audit(root, opts = {}) {
6855
- root = absRoot(root);
6856
- let warnings = 0, failures = 0;
6857
- // 1.9.35 개선 #5: --fix 옵션 — 자동 수정 가능한 항목 적용
6858
- const fix = has('--fix');
6859
- let fixed = 0;
6860
- // 1.9.102: --json 모드 — stdout 억제 후 구조화 출력
6861
- const jsonMode = !!opts.json || has('--json');
6862
- const findings = [];
6863
- const _finding = (kind, severity, message, details = {}) => findings.push({ kind, severity, message, ...details });
6864
- const _origWrite = process.stdout.write.bind(process.stdout);
6865
- if (jsonMode) process.stdout.write = () => true;
6866
- try {
6867
- // 외부리뷰 CV-3/UR-0078: 미초기화/존재하지 않는 경로를 healthy 로 오판하던 것 수정 — 필수 마커 부재 시 failure 승격(verify 와 일관).
6868
- if (!exists(root) || !exists(path.join(root, '.harness')) || !exists(path.join(root, 'AGENTS.md'))) {
6869
- failures++;
6870
- fail(`미초기화 또는 존재하지 않는 경로: ${root} (.harness/AGENTS.md 없음 — leerness init 필요)`);
6871
- _finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
6872
- }
6873
- const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
6874
- const dups = designCands.filter(f => exists(path.join(root,f)));
6875
- if (dups.length) { warnings++; warn(`design guide duplicates outside canonical: ${dups.join(', ')} (run: leerness consistency merge-design-guide)`); _finding('design_dup', 'warn', 'design guide duplicates outside canonical', { duplicates: dups }); }
6876
- else ok('no duplicate design guide candidates');
6877
- // 1.9.1 P4: <!-- leerness:na --> 마커가 있는 파일은 placeholder 경고 스킵.
6878
- const naMarker = '<!-- leerness:na';
6879
- const ds = exists(path.join(root,'.harness/design-system.md')) ? read(path.join(root,'.harness/design-system.md')) : '';
6880
- if (ds.includes(naMarker)) ok('design-system.md marked NA (skipped)');
6881
- else if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)/.test(ds)) { warnings++; warn('design-system.md tokens not customized'); _finding('design_system_default', 'warn', 'design-system.md tokens not customized'); }
6882
- else ok('design-system tokens populated');
6883
- const reuse = exists(path.join(root,'.harness/reuse-map.md')) ? read(path.join(root,'.harness/reuse-map.md')) : '';
6884
- const reuseLines = reuse.split('\n').filter(l => l.startsWith('|') && !/Capability|---/.test(l)).length;
6885
- if (reuse.includes(naMarker)) ok('reuse-map.md marked NA (skipped)');
6886
- else if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); _finding('reuse_map_empty', 'warn', 'reuse-map.md is empty'); }
6887
- else ok(`reuse-map.md has ${reuseLines} entries`);
6888
- const planText = exists(planPath(root)) ? read(planPath(root)) : '';
6889
- const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
6890
- const rows = readProgressRows(root);
6891
- // 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
6892
- const linkedMs = new Set(
6893
- rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4}/g), m => m[0]))
6894
- );
6895
- const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
6896
- if (missingFromProgress.length) {
6897
- warnings++;
6898
- warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`);
6899
- _finding('milestone_unlinked', 'warn', 'milestones without progress entry', { milestones: missingFromProgress });
6900
- log(` → 자동 매칭 제안: leerness task relink`);
6901
- log(` → 자동 적용: leerness task relink --apply`);
6902
- }
6903
- else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
6904
- const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
6905
- if (handoff.includes('Last generated: (자동)')) {
6906
- warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)');
6907
- _finding('handoff_not_generated', 'warn', 'session-handoff.md never auto-generated');
6908
- // 1.9.35 #5: --fix → session-handoff.md 자동 생성 마커 갱신
6909
- if (fix) {
6910
- const stamped = handoff.replace('Last generated: (자동)', `Last generated: ${today()} (leerness audit --fix)`);
6911
- writeUtf8(handoffPath(root), stamped);
6912
- ok(' ↳ fixed: session-handoff.md timestamp 갱신');
6913
- fixed++;
6914
- }
6915
- }
6916
- else if (handoff.includes('Last generated:')) ok('session-handoff.md auto-generated previously');
6917
- const cur = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
6918
- const updMatch = cur.match(/Updated: (\d{4}-\d{2}-\d{2})/);
6919
- if (updMatch) {
6920
- const dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
6921
- if (dDays > 7) {
6922
- warnings++; warn(`current-state.md stale (${Math.round(dDays)} days)`);
6923
- _finding('current_state_stale', 'warn', 'current-state.md stale', { days: Math.round(dDays) });
6924
- // 1.9.35 #5: --fix → current-state.md Updated 라인 갱신
6925
- if (fix) {
6926
- const stamped = cur.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
6927
- writeUtf8(currentStatePath(root), stamped);
6928
- ok(' ↳ fixed: current-state.md Updated 갱신');
6929
- fixed++;
6930
- }
6931
- }
6932
- else ok('current-state.md fresh');
6933
- }
6934
- // 1.9.40: README의 version 배지 ↔ package.json#version mismatch 감지 (도구 만드는 자가 자기 도구 stale하는 dogfooding gap 차단)
6935
- try {
6936
- const readmePath = path.join(root, 'README.md');
6937
- const pkgPath = path.join(root, 'package.json');
6938
- if (exists(readmePath) && exists(pkgPath)) {
6939
- const readmeText = read(readmePath);
6940
- const pkg = JSON.parse(read(pkgPath));
6941
- const m = readmeText.match(/badge\/version-(\d+\.\d+\.\d+)/);
6942
- if (pkg.version && m && m[1] !== pkg.version) {
6943
- warnings++;
6944
- warn(`README.md version badge mismatch: README=${m[1]} vs package.json=${pkg.version} (run: leerness readme sync)`);
6945
- _finding('readme_version_mismatch', 'warn', 'README.md version badge mismatch', { readme: m[1], pkg: pkg.version });
6946
- if (fix) {
6947
- const updated = readmeText.replace(/badge\/version-[\d.]+-(green|blue|red)/g, `badge/version-${pkg.version}-green`);
6948
- writeUtf8(readmePath, updated);
6949
- ok(' ↳ fixed: README.md version 배지 갱신');
6950
- fixed++;
6951
- }
6952
- }
6953
- }
6954
- } catch {}
6955
- // 1.9.62: package.json 있으면 npm audit --json 자동 호출 → CVE 보고 (opt-out: --no-npm-audit)
6956
- // 정책: leerness가 외부 호출하지만 사용자 컨텍스트에 이미 npm 설치되어 있음을 가정 (offline 시 자동 스킵)
6957
- if (exists(path.join(root, 'package.json')) && !has('--no-npm-audit') && process.env.LEERNESS_OFFLINE !== '1') {
6958
- try {
6959
- const r = cp.spawnSync('npm', ['audit', '--json'], {
6960
- cwd: root, encoding: 'utf8', shell: true, timeout: 30000
6961
- });
6962
- if (r.stdout) {
6963
- let j = null;
6964
- try { j = JSON.parse(r.stdout); } catch {}
6965
- if (j && j.metadata && j.metadata.vulnerabilities) {
6966
- const v = j.metadata.vulnerabilities;
6967
- const total = (v.critical || 0) + (v.high || 0) + (v.moderate || 0) + (v.low || 0);
6968
- if (total > 0) {
6969
- warnings++;
6970
- warn(`npm CVE: ${total}건 (critical=${v.critical||0}, high=${v.high||0}, moderate=${v.moderate||0}, low=${v.low||0})`);
6971
- _finding('npm_cve', 'warn', `npm CVE: ${total}건`, { vulnerabilities: v });
6972
- log(` → 수정: npm audit fix · 상세: npm audit`);
6973
- if (v.critical || v.high) {
6974
- warnings++; // critical/high는 추가 가중
6975
- warn(` ⚠ critical/high CVE 즉시 대응 권장`);
6976
- _finding('npm_cve_critical', 'warn', 'critical/high CVE 즉시 대응 권장', { critical: v.critical, high: v.high });
6977
- }
6978
- } else {
6979
- ok('npm CVE: 0건');
6980
- }
6981
- }
6982
- }
6983
- } catch {}
6984
- }
6985
- // 1.9.75: .gitignore 보안 검증 — .env / 시크릿 파일이 .gitignore에 포함되는지 (--no-gitignore-check로 끄기)
6986
- if (!has('--no-gitignore-check')) {
6987
- try {
6988
- const gi = path.join(root, '.gitignore');
6989
- const envPath = path.join(root, '.env');
6990
- if (exists(envPath)) {
6991
- // .env가 존재하면 .gitignore가 반드시 있어야 하고, .env가 포함되어야 함
6992
- const giText = exists(gi) ? read(gi) : '';
6993
- const giLines = giText.split('\n').map(l => l.trim());
6994
- // 필수 보안 패턴 (글로벌 룰 .gitignore 보안 체크리스트)
6995
- const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
6996
- const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
6997
- if (missing.length) {
6998
- warnings++;
6999
- warn(`.gitignore에 시크릿 패턴 ${missing.length}건 누락: ${missing.slice(0, 4).join(', ')}${missing.length > 4 ? ' …' : ''}`);
7000
- _finding('gitignore_missing_secrets', 'warn', '.gitignore에 시크릿 패턴 누락', { missing });
7001
- if (fix) {
7002
- // 자동 추가
7003
- let newGi = giText;
7004
- if (newGi && !newGi.endsWith('\n')) newGi += '\n';
7005
- newGi += `\n# 1.9.75 audit --fix: 시크릿 파일 보안 패턴 자동 추가 (사용자 글로벌 룰)\n`;
7006
- for (const p of missing) newGi += `${p}\n`;
7007
- writeUtf8(gi, newGi);
7008
- ok(` ↳ fixed: .gitignore에 ${missing.length}건 자동 추가 (시크릿 보안 1.9.75)`);
7009
- fixed++;
7010
- } else {
7011
- log(` → 자동 추가: leerness audit --fix`);
7012
- }
7013
- } else {
7014
- ok('.gitignore 시크릿 패턴 OK (1.9.75)');
7015
- }
7016
- }
7017
- } catch {}
7018
- }
7019
- // 1.9.71: .env / .env.example 동기화 감사 (--no-env-check로 끄기)
7020
- if (!has('--no-env-check')) {
7021
- try {
7022
- const d = envDiff(root);
7023
- if (exists(d.envPath) && exists(d.examplePath)) {
7024
- if (d.inEnvOnly.length) {
7025
- warnings++;
7026
- warn(`.env에 있는 키 ${d.inEnvOnly.length}건이 .env.example에 누락: ${d.inEnvOnly.slice(0, 4).join(', ')}${d.inEnvOnly.length > 4 ? ' …' : ''}`);
7027
- _finding('env_keys_missing', 'warn', '.env 키가 .env.example에 누락', { keys: d.inEnvOnly });
7028
- if (fix) {
7029
- // 자동 동기화: 누락 키만 .env.example 끝에 append (값 비움)
7030
- let example = read(d.examplePath);
7031
- if (!example.endsWith('\n')) example += '\n';
7032
- example += `\n# 1.9.71 audit --fix: 누락 키 자동 추가 (값은 빈 문자열, 보안 정책)\n`;
7033
- for (const k of d.inEnvOnly) example += `${k}=\n`;
7034
- writeUtf8(d.examplePath, example);
7035
- ok(` ↳ fixed: .env.example에 ${d.inEnvOnly.length}건 자동 추가 (값은 빈 문자열, 1.9.71)`);
7036
- fixed++;
7037
- } else {
7038
- log(` → 자동 동기화: leerness env sync 또는 leerness audit --fix`);
7039
- }
7040
- } else {
7041
- ok('.env ↔ .env.example 동기화됨 (1.9.71)');
7042
- }
7043
- }
7044
- } catch {}
7045
- }
7046
- // 1.9.142: Feature Graph 무결성 검증 — orphan/cycle 자동 감지 (--no-feature-check로 끄기)
7047
- if (!has('--no-feature-check')) {
7048
- try {
7049
- const { nodes: fNodes } = _readFeatureGraph(root);
7050
- if (fNodes.length > 0) {
7051
- const ids = new Set(fNodes.map(n => n.id));
7052
- // (1) orphan: 다른 노드가 참조하는데 정의가 없는 ID
7053
- const orphans = [];
7054
- for (const n of fNodes) {
7055
- for (const ref of [...(n.dependsOn || []), ...(n.affects || []), ...(n.coChangesWith || [])]) {
7056
- if (!ids.has(ref)) orphans.push({ from: n.id, missingRef: ref });
7057
- }
7058
- }
7059
- if (orphans.length) {
7060
- warnings++;
7061
- warn(`Feature Graph: orphan 참조 ${orphans.length}건 — ${orphans.slice(0, 3).map(o => `${o.from}→${o.missingRef}`).join(', ')}${orphans.length > 3 ? ' …' : ''}`);
7062
- _finding('feature_graph_orphan', 'warn', 'Feature Graph 에 정의되지 않은 ID 참조', { count: orphans.length, orphans: orphans.slice(0, 10) });
7063
- log(` → 수정: leerness feature add 또는 link 제거`);
7064
- }
7065
- // (2) cycle: affects 그래프에서 순환 의존성 감지 (DFS)
7066
- const cycles = [];
7067
- const WHITE = 0, GRAY = 1, BLACK = 2;
7068
- const color = new Map();
7069
- for (const n of fNodes) color.set(n.id, WHITE);
7070
- const byId = new Map(fNodes.map(n => [n.id, n]));
7071
- const dfs = (nodeId, path) => {
7072
- color.set(nodeId, GRAY);
7073
- const node = byId.get(nodeId);
7074
- if (!node) { color.set(nodeId, BLACK); return; }
7075
- for (const next of [...(node.affects || []), ...(node.dependsOn || [])]) {
7076
- if (!byId.has(next)) continue;
7077
- const c = color.get(next);
7078
- if (c === GRAY) {
7079
- // 순환 발견 — path 에 next 까지 자르기
7080
- const idx = path.indexOf(next);
7081
- const cyc = idx >= 0 ? path.slice(idx).concat([next]) : [...path, next];
7082
- if (!cycles.some(existing => existing.join() === cyc.join())) cycles.push(cyc);
7083
- } else if (c === WHITE) {
7084
- dfs(next, [...path, next]);
7085
- }
7086
- }
7087
- color.set(nodeId, BLACK);
7088
- };
7089
- for (const n of fNodes) if (color.get(n.id) === WHITE) dfs(n.id, [n.id]);
7090
- if (cycles.length) {
7091
- warnings++;
7092
- warn(`Feature Graph: 순환 의존 ${cycles.length}건 — ${cycles[0].join(' → ')}${cycles.length > 1 ? ` (외 ${cycles.length-1}건)` : ''}`);
7093
- _finding('feature_graph_cycle', 'warn', 'Feature Graph 에 순환 의존', { count: cycles.length, cycles: cycles.slice(0, 5) });
7094
- log(` → 수정: feature link 재구성 (affects/depends-on 방향 정리)`);
7095
- }
7096
- if (!orphans.length && !cycles.length) {
7097
- ok(`Feature Graph OK (${fNodes.length} 노드, orphan/cycle 없음, 1.9.142)`);
7098
- }
7099
- }
7100
- } catch {}
7101
- }
7102
- // 1.9.247 (UR-0015 2단계): api-skill 참조 audit — API 관련 task 인데 .harness/api-skills/ 미참조 시 경고
7103
- // 사용자 명시 (UR-0015): "AI가 정리해둔 파일이 참조되는지 확인"
7104
- // 현재 in-progress task 의 request/nextAction 에 API 키워드 (URL, "API", "endpoint", "REST", "GraphQL", "OAuth", "webhook") 있는데
7105
- // _matchAPISkills() 결과가 0 이면 → 경고 + leerness api-skill add <url> 안내
7106
- try {
7107
- const rows = readProgressRows(root);
7108
- const ip = rows.find(r => r.status === 'in-progress');
7109
- if (ip) {
7110
- const taskText = (ip.request || '') + ' ' + (ip.nextAction || '') + ' ' + (ip.evidence || '');
7111
- const apiKeywords = /\bAPI\b|endpoint|REST|GraphQL|OAuth|webhook|https?:\/\/[^\s]+/i;
7112
- if (apiKeywords.test(taskText)) {
7113
- const matched = _matchAPISkills(root, taskText);
7114
- const allSkills = _listAPISkills(root);
7115
- if (matched.length === 0) {
7116
- warnings++;
7117
- warn(`API 관련 task 감지 (현재: "${(ip.request || '').slice(0, 60)}") — .harness/api-skills/ 매칭 0건 (저장 ${allSkills.length})`);
7118
- warn(` → leerness api-skill add <url> --direction "구현 방향" 으로 정리 권장 (1.9.245 UR-0015 / 1.9.247 audit)`);
7119
- _finding('api_skill_missing', 'warn', 'API 관련 task 인데 .harness/api-skills/ 매칭 없음', {
7120
- taskRequest: (ip.request || '').slice(0, 100),
7121
- apiSkillsTotal: allSkills.length,
7122
- matched: 0,
7123
- hint: 'leerness api-skill add <url> --direction "..."'
7124
- });
7125
- } else {
7126
- ok(`API skill 매칭 OK (현재 task → ${matched.length}건 매칭 in .harness/api-skills/, 1.9.247 UR-0015 2단계)`);
7127
- }
7128
- }
7129
- }
7130
- } catch {}
7131
- // 1.9.63: --strict — warnings ≥ threshold 시 failures로 승격 (CI 친화)
7132
- if (has('--strict')) {
7133
- const threshold = parseInt(arg('--threshold', '1'), 10);
7134
- if (warnings >= threshold) {
7135
- failures++;
7136
- warn(`--strict 활성: warnings ${warnings} ≥ threshold ${threshold} → failures 승격`);
7137
- _finding('strict_promoted', 'fail', `warnings ${warnings} ≥ threshold ${threshold} → failures 승격`, { warnings, threshold });
7138
- }
7139
- }
7140
- log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}${has('--strict') ? ` strict-threshold=${arg('--threshold', '1')}` : ''}`);
7141
- } finally {
7142
- // 1.9.102: stdout 복원
7143
- if (jsonMode) process.stdout.write = _origWrite;
7144
- }
7145
- // 1.9.102: JSON 모드 — 구조화 출력
7146
- if (jsonMode) {
7147
- const payload = {
7148
- version: VERSION,
7149
- root,
7150
- warnings,
7151
- failures,
7152
- fixed,
7153
- healthy: failures === 0,
7154
- fixApplied: fix,
7155
- strict: has('--strict'),
7156
- strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null,
7157
- summary: `warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}`,
7158
- findings,
7159
- };
7160
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
7161
- }
7162
- if (failures) process.exitCode = 1;
7163
- }
6874
+ const _audit = require('../lib/audit');
6875
+ // 1.9.421 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째): audit → lib/audit.js (DI 위임, thin wrapper)
6876
+ function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills }); }
7164
6877
 
7165
6878
  // 1.9.312 (UR-0050, 설치리뷰 3중수렴): secret 스캐너 현대 키 패턴 보강.
7166
6879
  // 배경: 기존 OpenAI 패턴 `sk-[A-Za-z0-9]{32,}` 은 하이픈에서 끊겨 sk-proj-/sk-svcacct- (modern 프로젝트/서비스 키)를 놓침.
package/lib/audit.js ADDED
@@ -0,0 +1,322 @@
1
+ // lib/audit.js — audit 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째, 1.9.421)
2
+ // bin/leerness.js 에서 audit(310줄) 분리. DI: harness 고유 의존(VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills) 주입.
3
+ // io 프리미티브는 ./io, SECRET_PATTERNS 는 ./catalogs, cp/path 빌트인. 동작/출력 무변경.
4
+ 'use strict';
5
+ const cp = require('child_process');
6
+ const path = require('path');
7
+ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
8
+ const { SECRET_PATTERNS } = require('./catalogs');
9
+
10
+ function audit(root, opts = {}, deps = {}) {
11
+ const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills } = deps;
12
+ root = absRoot(root);
13
+ let warnings = 0, failures = 0;
14
+ // 1.9.35 개선 #5: --fix 옵션 — 자동 수정 가능한 항목 적용
15
+ const fix = has('--fix');
16
+ let fixed = 0;
17
+ // 1.9.102: --json 모드 — stdout 억제 후 구조화 출력
18
+ const jsonMode = !!opts.json || has('--json');
19
+ const findings = [];
20
+ const _finding = (kind, severity, message, details = {}) => findings.push({ kind, severity, message, ...details });
21
+ const _origWrite = process.stdout.write.bind(process.stdout);
22
+ if (jsonMode) process.stdout.write = () => true;
23
+ try {
24
+ // 외부리뷰 CV-3/UR-0078: 미초기화/존재하지 않는 경로를 healthy 로 오판하던 것 수정 — 필수 마커 부재 시 failure 승격(verify 와 일관).
25
+ if (!exists(root) || !exists(path.join(root, '.harness')) || !exists(path.join(root, 'AGENTS.md'))) {
26
+ failures++;
27
+ fail(`미초기화 또는 존재하지 않는 경로: ${root} (.harness/AGENTS.md 없음 — leerness init 필요)`);
28
+ _finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
29
+ }
30
+ const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
31
+ const dups = designCands.filter(f => exists(path.join(root,f)));
32
+ if (dups.length) { warnings++; warn(`design guide duplicates outside canonical: ${dups.join(', ')} (run: leerness consistency merge-design-guide)`); _finding('design_dup', 'warn', 'design guide duplicates outside canonical', { duplicates: dups }); }
33
+ else ok('no duplicate design guide candidates');
34
+ // 1.9.1 P4: <!-- leerness:na --> 마커가 있는 파일은 placeholder 경고 스킵.
35
+ const naMarker = '<!-- leerness:na';
36
+ const ds = exists(path.join(root,'.harness/design-system.md')) ? read(path.join(root,'.harness/design-system.md')) : '';
37
+ if (ds.includes(naMarker)) ok('design-system.md marked NA (skipped)');
38
+ else if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)/.test(ds)) { warnings++; warn('design-system.md tokens not customized'); _finding('design_system_default', 'warn', 'design-system.md tokens not customized'); }
39
+ else ok('design-system tokens populated');
40
+ const reuse = exists(path.join(root,'.harness/reuse-map.md')) ? read(path.join(root,'.harness/reuse-map.md')) : '';
41
+ const reuseLines = reuse.split('\n').filter(l => l.startsWith('|') && !/Capability|---/.test(l)).length;
42
+ if (reuse.includes(naMarker)) ok('reuse-map.md marked NA (skipped)');
43
+ else if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); _finding('reuse_map_empty', 'warn', 'reuse-map.md is empty'); }
44
+ else ok(`reuse-map.md has ${reuseLines} entries`);
45
+ const planText = exists(planPath(root)) ? read(planPath(root)) : '';
46
+ const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
47
+ const rows = readProgressRows(root);
48
+ // 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
49
+ const linkedMs = new Set(
50
+ rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4}/g), m => m[0]))
51
+ );
52
+ const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
53
+ if (missingFromProgress.length) {
54
+ warnings++;
55
+ warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`);
56
+ _finding('milestone_unlinked', 'warn', 'milestones without progress entry', { milestones: missingFromProgress });
57
+ log(` → 자동 매칭 제안: leerness task relink`);
58
+ log(` → 자동 적용: leerness task relink --apply`);
59
+ }
60
+ else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
61
+ const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
62
+ if (handoff.includes('Last generated: (자동)')) {
63
+ warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)');
64
+ _finding('handoff_not_generated', 'warn', 'session-handoff.md never auto-generated');
65
+ // 1.9.35 #5: --fix → session-handoff.md 자동 생성 마커 갱신
66
+ if (fix) {
67
+ const stamped = handoff.replace('Last generated: (자동)', `Last generated: ${today()} (leerness audit --fix)`);
68
+ writeUtf8(handoffPath(root), stamped);
69
+ ok(' ↳ fixed: session-handoff.md timestamp 갱신');
70
+ fixed++;
71
+ }
72
+ }
73
+ else if (handoff.includes('Last generated:')) ok('session-handoff.md auto-generated previously');
74
+ const cur = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
75
+ const updMatch = cur.match(/Updated: (\d{4}-\d{2}-\d{2})/);
76
+ if (updMatch) {
77
+ const dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
78
+ if (dDays > 7) {
79
+ warnings++; warn(`current-state.md stale (${Math.round(dDays)} days)`);
80
+ _finding('current_state_stale', 'warn', 'current-state.md stale', { days: Math.round(dDays) });
81
+ // 1.9.35 #5: --fix → current-state.md Updated 라인 갱신
82
+ if (fix) {
83
+ const stamped = cur.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
84
+ writeUtf8(currentStatePath(root), stamped);
85
+ ok(' ↳ fixed: current-state.md Updated 갱신');
86
+ fixed++;
87
+ }
88
+ }
89
+ else ok('current-state.md fresh');
90
+ }
91
+ // 1.9.40: README의 version 배지 ↔ package.json#version mismatch 감지 (도구 만드는 자가 자기 도구 stale하는 dogfooding gap 차단)
92
+ try {
93
+ const readmePath = path.join(root, 'README.md');
94
+ const pkgPath = path.join(root, 'package.json');
95
+ if (exists(readmePath) && exists(pkgPath)) {
96
+ const readmeText = read(readmePath);
97
+ const pkg = JSON.parse(read(pkgPath));
98
+ const m = readmeText.match(/badge\/version-(\d+\.\d+\.\d+)/);
99
+ if (pkg.version && m && m[1] !== pkg.version) {
100
+ warnings++;
101
+ warn(`README.md version badge mismatch: README=${m[1]} vs package.json=${pkg.version} (run: leerness readme sync)`);
102
+ _finding('readme_version_mismatch', 'warn', 'README.md version badge mismatch', { readme: m[1], pkg: pkg.version });
103
+ if (fix) {
104
+ const updated = readmeText.replace(/badge\/version-[\d.]+-(green|blue|red)/g, `badge/version-${pkg.version}-green`);
105
+ writeUtf8(readmePath, updated);
106
+ ok(' ↳ fixed: README.md version 배지 갱신');
107
+ fixed++;
108
+ }
109
+ }
110
+ }
111
+ } catch {}
112
+ // 1.9.62: package.json 있으면 npm audit --json 자동 호출 → CVE 보고 (opt-out: --no-npm-audit)
113
+ // 정책: leerness가 외부 호출하지만 사용자 컨텍스트에 이미 npm 설치되어 있음을 가정 (offline 시 자동 스킵)
114
+ if (exists(path.join(root, 'package.json')) && !has('--no-npm-audit') && process.env.LEERNESS_OFFLINE !== '1') {
115
+ try {
116
+ const r = cp.spawnSync('npm', ['audit', '--json'], {
117
+ cwd: root, encoding: 'utf8', shell: true, timeout: 30000
118
+ });
119
+ if (r.stdout) {
120
+ let j = null;
121
+ try { j = JSON.parse(r.stdout); } catch {}
122
+ if (j && j.metadata && j.metadata.vulnerabilities) {
123
+ const v = j.metadata.vulnerabilities;
124
+ const total = (v.critical || 0) + (v.high || 0) + (v.moderate || 0) + (v.low || 0);
125
+ if (total > 0) {
126
+ warnings++;
127
+ warn(`npm CVE: ${total}건 (critical=${v.critical||0}, high=${v.high||0}, moderate=${v.moderate||0}, low=${v.low||0})`);
128
+ _finding('npm_cve', 'warn', `npm CVE: ${total}건`, { vulnerabilities: v });
129
+ log(` → 수정: npm audit fix · 상세: npm audit`);
130
+ if (v.critical || v.high) {
131
+ warnings++; // critical/high는 추가 가중
132
+ warn(` ⚠ critical/high CVE 즉시 대응 권장`);
133
+ _finding('npm_cve_critical', 'warn', 'critical/high CVE 즉시 대응 권장', { critical: v.critical, high: v.high });
134
+ }
135
+ } else {
136
+ ok('npm CVE: 0건');
137
+ }
138
+ }
139
+ }
140
+ } catch {}
141
+ }
142
+ // 1.9.75: .gitignore 보안 검증 — .env / 시크릿 파일이 .gitignore에 포함되는지 (--no-gitignore-check로 끄기)
143
+ if (!has('--no-gitignore-check')) {
144
+ try {
145
+ const gi = path.join(root, '.gitignore');
146
+ const envPath = path.join(root, '.env');
147
+ if (exists(envPath)) {
148
+ // .env가 존재하면 .gitignore가 반드시 있어야 하고, .env가 포함되어야 함
149
+ const giText = exists(gi) ? read(gi) : '';
150
+ const giLines = giText.split('\n').map(l => l.trim());
151
+ // 필수 보안 패턴 (글로벌 룰 .gitignore 보안 체크리스트)
152
+ const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
153
+ const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
154
+ if (missing.length) {
155
+ warnings++;
156
+ warn(`.gitignore에 시크릿 패턴 ${missing.length}건 누락: ${missing.slice(0, 4).join(', ')}${missing.length > 4 ? ' …' : ''}`);
157
+ _finding('gitignore_missing_secrets', 'warn', '.gitignore에 시크릿 패턴 누락', { missing });
158
+ if (fix) {
159
+ // 자동 추가
160
+ let newGi = giText;
161
+ if (newGi && !newGi.endsWith('\n')) newGi += '\n';
162
+ newGi += `\n# 1.9.75 audit --fix: 시크릿 파일 보안 패턴 자동 추가 (사용자 글로벌 룰)\n`;
163
+ for (const p of missing) newGi += `${p}\n`;
164
+ writeUtf8(gi, newGi);
165
+ ok(` ↳ fixed: .gitignore에 ${missing.length}건 자동 추가 (시크릿 보안 1.9.75)`);
166
+ fixed++;
167
+ } else {
168
+ log(` → 자동 추가: leerness audit --fix`);
169
+ }
170
+ } else {
171
+ ok('.gitignore 시크릿 패턴 OK (1.9.75)');
172
+ }
173
+ }
174
+ } catch {}
175
+ }
176
+ // 1.9.71: .env / .env.example 동기화 감사 (--no-env-check로 끄기)
177
+ if (!has('--no-env-check')) {
178
+ try {
179
+ const d = envDiff(root);
180
+ if (exists(d.envPath) && exists(d.examplePath)) {
181
+ if (d.inEnvOnly.length) {
182
+ warnings++;
183
+ warn(`.env에 있는 키 ${d.inEnvOnly.length}건이 .env.example에 누락: ${d.inEnvOnly.slice(0, 4).join(', ')}${d.inEnvOnly.length > 4 ? ' …' : ''}`);
184
+ _finding('env_keys_missing', 'warn', '.env 키가 .env.example에 누락', { keys: d.inEnvOnly });
185
+ if (fix) {
186
+ // 자동 동기화: 누락 키만 .env.example 끝에 append (값 비움)
187
+ let example = read(d.examplePath);
188
+ if (!example.endsWith('\n')) example += '\n';
189
+ example += `\n# 1.9.71 audit --fix: 누락 키 자동 추가 (값은 빈 문자열, 보안 정책)\n`;
190
+ for (const k of d.inEnvOnly) example += `${k}=\n`;
191
+ writeUtf8(d.examplePath, example);
192
+ ok(` ↳ fixed: .env.example에 ${d.inEnvOnly.length}건 자동 추가 (값은 빈 문자열, 1.9.71)`);
193
+ fixed++;
194
+ } else {
195
+ log(` → 자동 동기화: leerness env sync 또는 leerness audit --fix`);
196
+ }
197
+ } else {
198
+ ok('.env ↔ .env.example 동기화됨 (1.9.71)');
199
+ }
200
+ }
201
+ } catch {}
202
+ }
203
+ // 1.9.142: Feature Graph 무결성 검증 — orphan/cycle 자동 감지 (--no-feature-check로 끄기)
204
+ if (!has('--no-feature-check')) {
205
+ try {
206
+ const { nodes: fNodes } = _readFeatureGraph(root);
207
+ if (fNodes.length > 0) {
208
+ const ids = new Set(fNodes.map(n => n.id));
209
+ // (1) orphan: 다른 노드가 참조하는데 정의가 없는 ID
210
+ const orphans = [];
211
+ for (const n of fNodes) {
212
+ for (const ref of [...(n.dependsOn || []), ...(n.affects || []), ...(n.coChangesWith || [])]) {
213
+ if (!ids.has(ref)) orphans.push({ from: n.id, missingRef: ref });
214
+ }
215
+ }
216
+ if (orphans.length) {
217
+ warnings++;
218
+ warn(`Feature Graph: orphan 참조 ${orphans.length}건 — ${orphans.slice(0, 3).map(o => `${o.from}→${o.missingRef}`).join(', ')}${orphans.length > 3 ? ' …' : ''}`);
219
+ _finding('feature_graph_orphan', 'warn', 'Feature Graph 에 정의되지 않은 ID 참조', { count: orphans.length, orphans: orphans.slice(0, 10) });
220
+ log(` → 수정: leerness feature add 또는 link 제거`);
221
+ }
222
+ // (2) cycle: affects 그래프에서 순환 의존성 감지 (DFS)
223
+ const cycles = [];
224
+ const WHITE = 0, GRAY = 1, BLACK = 2;
225
+ const color = new Map();
226
+ for (const n of fNodes) color.set(n.id, WHITE);
227
+ const byId = new Map(fNodes.map(n => [n.id, n]));
228
+ const dfs = (nodeId, path) => {
229
+ color.set(nodeId, GRAY);
230
+ const node = byId.get(nodeId);
231
+ if (!node) { color.set(nodeId, BLACK); return; }
232
+ for (const next of [...(node.affects || []), ...(node.dependsOn || [])]) {
233
+ if (!byId.has(next)) continue;
234
+ const c = color.get(next);
235
+ if (c === GRAY) {
236
+ // 순환 발견 — path 에 next 까지 자르기
237
+ const idx = path.indexOf(next);
238
+ const cyc = idx >= 0 ? path.slice(idx).concat([next]) : [...path, next];
239
+ if (!cycles.some(existing => existing.join() === cyc.join())) cycles.push(cyc);
240
+ } else if (c === WHITE) {
241
+ dfs(next, [...path, next]);
242
+ }
243
+ }
244
+ color.set(nodeId, BLACK);
245
+ };
246
+ for (const n of fNodes) if (color.get(n.id) === WHITE) dfs(n.id, [n.id]);
247
+ if (cycles.length) {
248
+ warnings++;
249
+ warn(`Feature Graph: 순환 의존 ${cycles.length}건 — ${cycles[0].join(' → ')}${cycles.length > 1 ? ` (외 ${cycles.length-1}건)` : ''}`);
250
+ _finding('feature_graph_cycle', 'warn', 'Feature Graph 에 순환 의존', { count: cycles.length, cycles: cycles.slice(0, 5) });
251
+ log(` → 수정: feature link 재구성 (affects/depends-on 방향 정리)`);
252
+ }
253
+ if (!orphans.length && !cycles.length) {
254
+ ok(`Feature Graph OK (${fNodes.length} 노드, orphan/cycle 없음, 1.9.142)`);
255
+ }
256
+ }
257
+ } catch {}
258
+ }
259
+ // 1.9.247 (UR-0015 2단계): api-skill 참조 audit — API 관련 task 인데 .harness/api-skills/ 미참조 시 경고
260
+ // 사용자 명시 (UR-0015): "AI가 정리해둔 파일이 참조되는지 확인"
261
+ // 현재 in-progress task 의 request/nextAction 에 API 키워드 (URL, "API", "endpoint", "REST", "GraphQL", "OAuth", "webhook") 있는데
262
+ // _matchAPISkills() 결과가 0 이면 → 경고 + leerness api-skill add <url> 안내
263
+ try {
264
+ const rows = readProgressRows(root);
265
+ const ip = rows.find(r => r.status === 'in-progress');
266
+ if (ip) {
267
+ const taskText = (ip.request || '') + ' ' + (ip.nextAction || '') + ' ' + (ip.evidence || '');
268
+ const apiKeywords = /\bAPI\b|endpoint|REST|GraphQL|OAuth|webhook|https?:\/\/[^\s]+/i;
269
+ if (apiKeywords.test(taskText)) {
270
+ const matched = _matchAPISkills(root, taskText);
271
+ const allSkills = _listAPISkills(root);
272
+ if (matched.length === 0) {
273
+ warnings++;
274
+ warn(`API 관련 task 감지 (현재: "${(ip.request || '').slice(0, 60)}") — .harness/api-skills/ 매칭 0건 (저장 ${allSkills.length})`);
275
+ warn(` → leerness api-skill add <url> --direction "구현 방향" 으로 정리 권장 (1.9.245 UR-0015 / 1.9.247 audit)`);
276
+ _finding('api_skill_missing', 'warn', 'API 관련 task 인데 .harness/api-skills/ 매칭 없음', {
277
+ taskRequest: (ip.request || '').slice(0, 100),
278
+ apiSkillsTotal: allSkills.length,
279
+ matched: 0,
280
+ hint: 'leerness api-skill add <url> --direction "..."'
281
+ });
282
+ } else {
283
+ ok(`API skill 매칭 OK (현재 task → ${matched.length}건 매칭 in .harness/api-skills/, 1.9.247 UR-0015 2단계)`);
284
+ }
285
+ }
286
+ }
287
+ } catch {}
288
+ // 1.9.63: --strict — warnings ≥ threshold 시 failures로 승격 (CI 친화)
289
+ if (has('--strict')) {
290
+ const threshold = parseInt(arg('--threshold', '1'), 10);
291
+ if (warnings >= threshold) {
292
+ failures++;
293
+ warn(`--strict 활성: warnings ${warnings} ≥ threshold ${threshold} → failures 승격`);
294
+ _finding('strict_promoted', 'fail', `warnings ${warnings} ≥ threshold ${threshold} → failures 승격`, { warnings, threshold });
295
+ }
296
+ }
297
+ log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}${has('--strict') ? ` strict-threshold=${arg('--threshold', '1')}` : ''}`);
298
+ } finally {
299
+ // 1.9.102: stdout 복원
300
+ if (jsonMode) process.stdout.write = _origWrite;
301
+ }
302
+ // 1.9.102: JSON 모드 — 구조화 출력
303
+ if (jsonMode) {
304
+ const payload = {
305
+ version: VERSION,
306
+ root,
307
+ warnings,
308
+ failures,
309
+ fixed,
310
+ healthy: failures === 0,
311
+ fixApplied: fix,
312
+ strict: has('--strict'),
313
+ strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null,
314
+ summary: `warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}`,
315
+ findings,
316
+ };
317
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
318
+ }
319
+ if (failures) process.exitCode = 1;
320
+ }
321
+
322
+ module.exports = { audit };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.420",
3
+ "version": "1.9.421",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",