leerness 1.9.1 → 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 +62 -0
- package/bin/harness.js +487 -39
- package/package.json +1 -1
- package/scripts/e2e.js +154 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
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
|
+
|
|
22
|
+
## 1.9.3 — 2026-05-08
|
|
23
|
+
|
|
24
|
+
이번 릴리스는 "이전 작업과 새 작업의 인과관계·재귀 안내·디자인 일관성"을 자동화합니다.
|
|
25
|
+
|
|
26
|
+
### Added — 인과관계·재사용·일관성
|
|
27
|
+
|
|
28
|
+
- `leerness impact <target>` — 변경 전 영향 분석. `<target>`을 `import/require/href/src/@import/url()`로 참조하는 모든 파일을 단일 패스로 식별.
|
|
29
|
+
- `leerness reuse find <query>` — `reuse-map.md`, `design-system.md`, `feature-contracts.md`, `plan/progress`, 그리고 코드의 export/식별자에서 기존 자원을 통합 검색.
|
|
30
|
+
- `leerness reuse register <name> --where <path> --kind component|hook|util|api [--note ...]` — `reuse-map.md`에 자동 row 추가.
|
|
31
|
+
- `leerness ui consistency [path] [--strict] [--fail-on-violation]` — `design-system.md`의 토큰 표를 파싱해 코드의 hex 색상이 토큰에 등록되어 있는지 검사. `--strict`는 px/rem 사이즈도, `--fail-on-violation`은 비-제로 종료.
|
|
32
|
+
- `leerness graph [path] [--out <file>]` — 의존성 그래프를 mermaid 형식으로 출력하거나 파일로 저장.
|
|
33
|
+
- `leerness guide [target]` — 위 4개를 한 번에 실행하는 변경 전 통합 가이드.
|
|
34
|
+
|
|
35
|
+
### Migration
|
|
36
|
+
|
|
37
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
|
|
38
|
+
|
|
39
|
+
## 1.9.2 — 2026-05-08
|
|
40
|
+
|
|
41
|
+
스킬을 살아 있는 학습 사이클로 끌어올린 릴리스. 동일 API 작업이 반복될 때 기존 패턴을 발견·재사용하고, 더 나은 방법이 생기면 최적화 이력으로 누적합니다.
|
|
42
|
+
|
|
43
|
+
### Added — 스킬 학습 사이클
|
|
44
|
+
|
|
45
|
+
- `leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]`
|
|
46
|
+
- 새 스킬을 `.harness/skills/<id>/skill.json`에 생성하거나, 카탈로그 스킬을 로컬에 materialize.
|
|
47
|
+
- `--doc` / `--capability`는 반복 가능 (n번 적으면 모두 누적).
|
|
48
|
+
- `skill.json` 스키마 확장: `sources[]`, `patterns[]`, `optimizations[]`, `usage{count,lastUsed,lastNote}`.
|
|
49
|
+
- `leerness skill use <id> [--note ...]`: 사용 횟수+1, lastUsed 갱신.
|
|
50
|
+
- `leerness skill optimize <id> --before "..." --after "..." [--note ...]`: 최적화 이력 누적.
|
|
51
|
+
- `leerness skill remove <id>`: 사용자 정의 스킬 삭제 (카탈로그 스킬은 로컬 메타만 정리).
|
|
52
|
+
- `leerness skill consolidate [--threshold 0.3]`: 모든 스킬의 capability 토큰 jaccard 비교로 통합 후보 자동 발견.
|
|
53
|
+
- `leerness skill list`가 카탈로그 + 사용자 스킬을 합쳐 출력 (출처/사용횟수/최종 컬럼 추가).
|
|
54
|
+
- `leerness skill info <id>`가 sources/patterns/optimizations까지 모두 표시.
|
|
55
|
+
|
|
56
|
+
### Added — 게이트 통합
|
|
57
|
+
|
|
58
|
+
- `leerness gate [path]` — `verify + audit + scan secrets + encoding check + lazy detect`을 한번에 실행해 단일 요약을 출력. 한 단계라도 실패하면 비-제로 종료.
|
|
59
|
+
- `leerness self check`을 `leerness update --check`의 thin wrapper로 통합. 단일 출처(npm view + 캐시)로 일원화하면서 1.8.0과의 호환을 위해 명령 자체는 유지.
|
|
60
|
+
|
|
61
|
+
### Migration
|
|
62
|
+
|
|
63
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. 카탈로그 스킬에 대한 사용 기록은 처음 `skill use`/`skill optimize` 시점부터 누적되기 시작합니다.
|
|
64
|
+
|
|
3
65
|
## 1.9.1 — 2026-05-08
|
|
4
66
|
|
|
5
67
|
1.9.0을 실 프로젝트(memo-cli)에서 운영하며 발견한 **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.
|
|
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 -->';
|
|
@@ -56,7 +56,7 @@ function arg(name, def = null) { const i = process.argv.indexOf(name); return i
|
|
|
56
56
|
function has(name) { return process.argv.includes(name); }
|
|
57
57
|
function nonFlagArgs() {
|
|
58
58
|
const out = [];
|
|
59
|
-
const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool']);
|
|
59
|
+
const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold']);
|
|
60
60
|
const a = process.argv.slice(2);
|
|
61
61
|
for (let i = 0; i < a.length; i++) {
|
|
62
62
|
const x = a[i];
|
|
@@ -65,6 +65,11 @@ function nonFlagArgs() {
|
|
|
65
65
|
}
|
|
66
66
|
return out;
|
|
67
67
|
}
|
|
68
|
+
function argAll(name) {
|
|
69
|
+
const out = []; const a = process.argv;
|
|
70
|
+
for (let i = 0; i < a.length; i++) if (a[i] === name && a[i+1] && !a[i+1].startsWith('-')) out.push(a[++i]);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
68
73
|
function detectProjectName(root) { try { const pkg = JSON.parse(read(path.join(root, 'package.json'))); if (pkg.name) return pkg.name; } catch {} return path.basename(root); }
|
|
69
74
|
function detectLanguageValue(root, value = 'auto') {
|
|
70
75
|
const v = String(value || 'auto').toLowerCase();
|
|
@@ -404,32 +409,162 @@ function addSkill(root, name, silent = false) {
|
|
|
404
409
|
if (!silent) ok(`skill installed: ${name}`);
|
|
405
410
|
}
|
|
406
411
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
412
|
+
// ===== Skill registry (catalog + user-defined merged) =====
|
|
413
|
+
function userSkillsDir(root) { return path.join(absRoot(root), '.harness/skills'); }
|
|
414
|
+
function userSkillFile(root, id) { return path.join(userSkillsDir(root), id, 'skill.json'); }
|
|
415
|
+
|
|
416
|
+
function loadUserSkill(root, id) {
|
|
417
|
+
const f = userSkillFile(root, id);
|
|
418
|
+
if (!exists(f)) return null;
|
|
419
|
+
try { return JSON.parse(read(f)); } catch { return null; }
|
|
420
|
+
}
|
|
421
|
+
function saveUserSkill(root, id, data) {
|
|
422
|
+
const dir = path.join(userSkillsDir(root), id); mkdirp(dir);
|
|
423
|
+
writeUtf8(path.join(dir, 'skill.json'), JSON.stringify(data, null, 2) + '\n');
|
|
424
|
+
// README mirror
|
|
425
|
+
const usage = data.usage || { count: 0 };
|
|
426
|
+
const readme = `# ${data.displayNameKo || id}\n\n## Capabilities\n${(data.capabilities || []).map(c => '- ' + c).join('\n') || '-'}\n\n## Sources\n${(data.sources || []).map(s => `- ${s.url || s}`).join('\n') || '-'}\n\n## Patterns (성공 명령/접근)\n${(data.patterns || []).map(p => `- \`${p.command}\` — ${p.note || ''}`).join('\n') || '-'}\n\n## Optimization history\n${(data.optimizations || []).map(o => `- ${o.at}: ${o.note || ''}${o.before||o.after?` (${o.before||'?'} → ${o.after||'?'})`:''}`).join('\n') || '-'}\n\n## Usage\n${usage.count || 0}회 사용 / 마지막: ${usage.lastUsed || '-'}\n${usage.lastNote ? '\n마지막 노트: ' + usage.lastNote : ''}\n`;
|
|
427
|
+
writeUtf8(path.join(dir, 'README.md'), readme);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function listAllSkills(root) {
|
|
431
|
+
const out = {};
|
|
432
|
+
for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: 'catalog' };
|
|
410
433
|
if (root) {
|
|
411
|
-
const
|
|
412
|
-
if (exists(
|
|
413
|
-
|
|
434
|
+
const dir = userSkillsDir(root);
|
|
435
|
+
if (exists(dir)) {
|
|
436
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
437
|
+
if (!e.isDirectory()) continue;
|
|
438
|
+
const data = loadUserSkill(root, e.name);
|
|
439
|
+
if (!data) continue;
|
|
440
|
+
if (out[e.name]) out[e.name] = { ...out[e.name], ...data, _source: 'catalog+local' };
|
|
441
|
+
else out[e.name] = { ...data, _source: 'user' };
|
|
442
|
+
}
|
|
414
443
|
}
|
|
415
444
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function skillList(root) {
|
|
449
|
+
const all = listAllSkills(root);
|
|
450
|
+
log('| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |');
|
|
451
|
+
log('|---|---|---|---|---|---|');
|
|
452
|
+
for (const [id, v] of Object.entries(all)) {
|
|
453
|
+
const cap = (v.capabilities || []).slice(0, 3).join(' / ') + ((v.capabilities || []).length > 3 ? ' …' : '');
|
|
454
|
+
const usage = v.usage?.count || 0;
|
|
455
|
+
const last = v.usage?.lastUsed?.slice(0, 10) || v.lastUpdated || '-';
|
|
456
|
+
log(`| ${id} | ${v.displayNameKo || id} | ${v._source} | ${cap} | ${usage} | ${last} |`);
|
|
425
457
|
}
|
|
426
458
|
}
|
|
427
|
-
|
|
428
|
-
|
|
459
|
+
|
|
460
|
+
function skillInfo(name, root) {
|
|
461
|
+
const all = listAllSkills(root);
|
|
462
|
+
const v = all[name];
|
|
429
463
|
if (!v) return fail(`Unknown skill: ${name}`);
|
|
430
|
-
log(
|
|
431
|
-
log(
|
|
432
|
-
log(
|
|
464
|
+
log(`# ${name} (${v._source})`);
|
|
465
|
+
log(`한글명: ${v.displayNameKo || name}`);
|
|
466
|
+
log(`버전: ${v.version || '-'} / 최종: ${v.lastUpdated || '-'} / 검증: ${typeof v.verification === 'object' ? v.verification.status : v.verification || '-'}`);
|
|
467
|
+
log(`사용: ${v.usage?.count || 0}회 / 마지막: ${v.usage?.lastUsed || '-'}`);
|
|
468
|
+
log('Capabilities:'); (v.capabilities || []).forEach(x => log('- ' + x));
|
|
469
|
+
if ((v.sources || []).length) { log('Sources:'); v.sources.forEach(s => log('- ' + (s.url || s))); }
|
|
470
|
+
if ((v.patterns || []).length) { log('Patterns:'); v.patterns.forEach(p => log(`- \`${p.command}\` — ${p.note || ''}`)); }
|
|
471
|
+
if ((v.optimizations || []).length) { log('Optimizations:'); v.optimizations.forEach(o => log(`- ${o.at}: ${o.note || ''}`)); }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function skillLearn(root, id) {
|
|
475
|
+
if (!id) return fail('id required (e.g., skill learn open-meteo --command "..." --doc URL)');
|
|
476
|
+
const docs = argAll('--doc');
|
|
477
|
+
const command = arg('--command', null);
|
|
478
|
+
const note = arg('--note', null);
|
|
479
|
+
const caps = argAll('--capability');
|
|
480
|
+
const display = arg('--display', null);
|
|
481
|
+
let data = loadUserSkill(root, id);
|
|
482
|
+
if (!data) {
|
|
483
|
+
// start from catalog if exists
|
|
484
|
+
const base = skillCatalog[id];
|
|
485
|
+
data = base ? { name: id, ...base, sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } }
|
|
486
|
+
: { name: id, displayNameKo: display || id, version: '1.0.0', lastUpdated: today(), verification: 'unverified', capabilities: [], sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } };
|
|
487
|
+
}
|
|
488
|
+
if (display) data.displayNameKo = display;
|
|
489
|
+
for (const d of docs) if (!data.sources.some(s => (s.url || s) === d)) data.sources.push({ at: now(), url: d });
|
|
490
|
+
for (const c of caps) if (!data.capabilities.includes(c)) data.capabilities.push(c);
|
|
491
|
+
if (command) data.patterns.push({ at: now(), command, note: note || '' });
|
|
492
|
+
data.lastUpdated = today();
|
|
493
|
+
saveUserSkill(root, id, data);
|
|
494
|
+
ok(`skill learned: ${id} (sources=${data.sources.length}, patterns=${data.patterns.length}, capabilities=${data.capabilities.length})`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function skillUse(root, id) {
|
|
498
|
+
if (!id) return fail('id required');
|
|
499
|
+
let data = loadUserSkill(root, id);
|
|
500
|
+
if (!data) {
|
|
501
|
+
const base = skillCatalog[id];
|
|
502
|
+
if (!base) return fail(`skill not found: ${id}`);
|
|
503
|
+
data = { name: id, ...base, sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } };
|
|
504
|
+
}
|
|
505
|
+
data.usage = data.usage || { count: 0, lastUsed: null };
|
|
506
|
+
data.usage.count++;
|
|
507
|
+
data.usage.lastUsed = now();
|
|
508
|
+
if (arg('--note', null)) data.usage.lastNote = arg('--note', null);
|
|
509
|
+
saveUserSkill(root, id, data);
|
|
510
|
+
ok(`skill used: ${id} (count=${data.usage.count})`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function skillOptimize(root, id) {
|
|
514
|
+
if (!id) return fail('id required');
|
|
515
|
+
let data = loadUserSkill(root, id);
|
|
516
|
+
if (!data) {
|
|
517
|
+
const base = skillCatalog[id];
|
|
518
|
+
if (!base) return fail(`skill not found: ${id}`);
|
|
519
|
+
data = { name: id, ...base, sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } };
|
|
520
|
+
}
|
|
521
|
+
data.optimizations = data.optimizations || [];
|
|
522
|
+
data.optimizations.push({ at: now(), before: arg('--before', '') || '', after: arg('--after', '') || '', note: arg('--note', '') || '' });
|
|
523
|
+
data.lastUpdated = today();
|
|
524
|
+
saveUserSkill(root, id, data);
|
|
525
|
+
ok(`skill optimized: ${id} (total=${data.optimizations.length})`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function skillRemove(root, id) {
|
|
529
|
+
if (!id) return fail('id required');
|
|
530
|
+
const dir = path.join(userSkillsDir(root), id);
|
|
531
|
+
if (!exists(dir)) return fail(`skill folder not found: ${id}`);
|
|
532
|
+
if (skillCatalog[id]) {
|
|
533
|
+
// catalog 스킬은 로컬 메타만 제거 (카탈로그는 패키지 내장이라 영구 제거 불가)
|
|
534
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
535
|
+
ok(`local meta removed for catalog skill: ${id} (catalog 자체는 패키지에 내장)`);
|
|
536
|
+
} else {
|
|
537
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
538
|
+
ok(`user skill removed: ${id}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function skillConsolidate(root) {
|
|
543
|
+
const all = listAllSkills(root);
|
|
544
|
+
const ids = Object.keys(all);
|
|
545
|
+
function tokens(v) {
|
|
546
|
+
return new Set((v.capabilities || []).join(' ').toLowerCase().split(/[\s,/·.()[\]]+/).filter(t => t.length >= 2));
|
|
547
|
+
}
|
|
548
|
+
function jaccard(a, b) {
|
|
549
|
+
const inter = new Set([...a].filter(x => b.has(x))).size;
|
|
550
|
+
const uni = new Set([...a, ...b]).size;
|
|
551
|
+
return uni ? inter / uni : 0;
|
|
552
|
+
}
|
|
553
|
+
const tokenized = {};
|
|
554
|
+
for (const id of ids) tokenized[id] = tokens(all[id]);
|
|
555
|
+
const threshold = parseFloat(arg('--threshold', '0.3'));
|
|
556
|
+
const candidates = [];
|
|
557
|
+
for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
|
|
558
|
+
const a = ids[i], b = ids[j];
|
|
559
|
+
const s = jaccard(tokenized[a], tokenized[b]);
|
|
560
|
+
if (s >= threshold) candidates.push({ a, b, score: s });
|
|
561
|
+
}
|
|
562
|
+
if (!candidates.length) return ok(`no consolidation candidates (jaccard < ${threshold})`);
|
|
563
|
+
candidates.sort((x, y) => y.score - x.score);
|
|
564
|
+
log(`# Consolidation candidates (jaccard >= ${threshold})`);
|
|
565
|
+
log('| A | B | score | 권장 |');
|
|
566
|
+
log('|---|---|---|---|');
|
|
567
|
+
for (const c of candidates) log(`| ${c.a} | ${c.b} | ${c.score.toFixed(2)} | \`leerness skill learn <new> --capability ...\` 후 \`leerness skill remove <old>\` |`);
|
|
433
568
|
}
|
|
434
569
|
|
|
435
570
|
const planPath = root => path.join(root, '.harness/plan.md');
|
|
@@ -544,6 +679,37 @@ function taskDrop(root, id) {
|
|
|
544
679
|
ok(`task dropped: ${id}`);
|
|
545
680
|
}
|
|
546
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
|
+
|
|
547
713
|
function route(name) {
|
|
548
714
|
const r = routes[name];
|
|
549
715
|
if (!r) { fail('Unknown route'); log('Available: ' + Object.keys(routes).join(', ')); return; }
|
|
@@ -636,15 +802,26 @@ const SECRET_PATTERNS = [
|
|
|
636
802
|
{ name: 'Generic private key', re: /-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----/g },
|
|
637
803
|
{ name: 'Hardcoded password assignment', re: /\b(?:password|passwd|pwd|secret|api_key|apikey)\s*[:=]\s*["'][^"'\s]{6,}["']/gi },
|
|
638
804
|
];
|
|
639
|
-
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
|
+
}
|
|
640
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','']);
|
|
641
|
-
function* walk(root, base = root, depth = 0) {
|
|
817
|
+
function* walk(root, base = root, depth = 0, extras = null) {
|
|
642
818
|
if (depth > 12) return;
|
|
819
|
+
if (extras === null) extras = getExtraSkipDirs(root);
|
|
643
820
|
for (const e of fs.readdirSync(base, { withFileTypes: true })) {
|
|
644
821
|
const p = path.join(base, e.name);
|
|
645
822
|
const r = path.relative(root, p).replace(/\\/g, '/');
|
|
646
|
-
if (
|
|
647
|
-
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);
|
|
648
825
|
else yield p;
|
|
649
826
|
}
|
|
650
827
|
}
|
|
@@ -736,12 +913,32 @@ function lazyDetect(root) {
|
|
|
736
913
|
const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
|
|
737
914
|
const hasTestRun = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress)\b/i.test(ev);
|
|
738
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
|
+
}
|
|
739
924
|
let todoCount = 0;
|
|
925
|
+
const cliSelf = path.resolve(__filename);
|
|
740
926
|
for (const file of walk(root)) {
|
|
741
927
|
const ext = path.extname(file).toLowerCase();
|
|
742
|
-
if (!SCAN_TEXT_EXT.has(ext)
|
|
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;
|
|
743
932
|
let text; try { text = read(file); } catch { continue; }
|
|
744
|
-
|
|
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
|
+
}
|
|
745
942
|
}
|
|
746
943
|
if (todoCount > 0) {
|
|
747
944
|
const hasTodoTask = rows.some(r => /TODO|FIXME|XXX/.test(r.request) || /TODO|FIXME|XXX/i.test(r.evidence));
|
|
@@ -900,13 +1097,245 @@ function mergeDesign(root) {
|
|
|
900
1097
|
ok(merged ? 'design guides merged into .harness/design-system.md' : 'nothing to merge');
|
|
901
1098
|
}
|
|
902
1099
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1100
|
+
// 1.9.2: self check를 update --check의 thin wrapper로 통합 (단일 출처).
|
|
1101
|
+
async function selfCheck(root) {
|
|
1102
|
+
return await updateCmd(root, { checkOnly: true });
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// 1.9.2: 게이트 5종 한번에 실행 (verify + audit + scan secrets + encoding check + lazy detect).
|
|
1106
|
+
function gate(root) {
|
|
1107
|
+
root = absRoot(root);
|
|
1108
|
+
log('# leerness gate (5 checks)');
|
|
1109
|
+
let bad = 0;
|
|
1110
|
+
function step(label, fn) {
|
|
1111
|
+
log(`\n## ${label}`);
|
|
1112
|
+
const code0 = process.exitCode || 0;
|
|
1113
|
+
try { fn(); } catch (e) { fail(`${label} threw: ${e.message}`); bad++; }
|
|
1114
|
+
if (process.exitCode && process.exitCode !== code0) bad++;
|
|
1115
|
+
process.exitCode = 0;
|
|
1116
|
+
}
|
|
1117
|
+
step('verify', () => verify(root));
|
|
1118
|
+
step('audit', () => audit(root));
|
|
1119
|
+
step('scan secrets', () => scanSecrets(root));
|
|
1120
|
+
step('encoding check', () => encodingCheck(root));
|
|
1121
|
+
step('lazy detect', () => lazyDetect(root));
|
|
1122
|
+
log(`\n# gate summary: ${bad} 단계 실패`);
|
|
1123
|
+
if (bad) process.exitCode = 1;
|
|
1124
|
+
else ok('all gates passed');
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ===== 1.9.3: Causal / reuse / consistency =====
|
|
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']);
|
|
1129
|
+
function* walkCode(root, base = root, depth = 0, extras = null) {
|
|
1130
|
+
if (depth > 12) return;
|
|
1131
|
+
if (extras === null) extras = getExtraSkipDirs(root);
|
|
1132
|
+
for (const e of fs.readdirSync(base, { withFileTypes: true })) {
|
|
1133
|
+
const p = path.join(base, e.name);
|
|
1134
|
+
const r = path.relative(root, p).replace(/\\/g, '/');
|
|
1135
|
+
if (isSkippedRel(r, extras)) continue;
|
|
1136
|
+
if (e.isDirectory()) yield* walkCode(root, p, depth + 1, extras);
|
|
1137
|
+
else if (CODE_EXT.has(path.extname(p).toLowerCase())) yield p;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function escapeRegex(s) { return String(s).replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); }
|
|
1141
|
+
|
|
1142
|
+
function impactCmd(root, target) {
|
|
1143
|
+
if (!target) return fail('target required (e.g., impact src/components/card.html)');
|
|
1144
|
+
root = absRoot(root);
|
|
1145
|
+
const abs = path.isAbsolute(target) ? target : path.resolve(root, target);
|
|
1146
|
+
const base = path.basename(target);
|
|
1147
|
+
const noext = path.basename(target, path.extname(target));
|
|
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 = [];
|
|
1164
|
+
for (const f of walkCode(root)) {
|
|
1165
|
+
if (path.resolve(f) === path.resolve(abs)) continue;
|
|
1166
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1167
|
+
if (strongRe.test(text)) high.push(rel(root, f));
|
|
1168
|
+
else if (weakRe.test(text)) low.push(rel(root, f));
|
|
1169
|
+
}
|
|
1170
|
+
log(`# impact: ${targetRel}`);
|
|
1171
|
+
const showAll = has('--all');
|
|
1172
|
+
if (high.length === 0 && (low.length === 0 || !showAll)) ok('영향 범위 없음 (강한 참조 없음)');
|
|
1173
|
+
else {
|
|
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
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return { target: targetRel, high, low };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function reuseMapPath(root) { return path.join(root, '.harness/reuse-map.md'); }
|
|
1189
|
+
function designSystemPath(root) { return path.join(root, '.harness/design-system.md'); }
|
|
1190
|
+
function featureContractsPath(root) { return path.join(root, '.harness/feature-contracts.md'); }
|
|
1191
|
+
|
|
1192
|
+
function reuseFind(root, query) {
|
|
1193
|
+
if (!query) return fail('query required');
|
|
1194
|
+
root = absRoot(root);
|
|
1195
|
+
const re = new RegExp(escapeRegex(query), 'i');
|
|
1196
|
+
const matches = [];
|
|
1197
|
+
for (const src of [reuseMapPath(root), designSystemPath(root), featureContractsPath(root), planPath(root), progressPath(root)]) {
|
|
1198
|
+
if (!exists(src)) continue;
|
|
1199
|
+
const text = read(src);
|
|
1200
|
+
const lines = text.split('\n');
|
|
1201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1202
|
+
if (re.test(lines[i])) matches.push({ source: rel(root, src), line: i + 1, text: lines[i].trim() });
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
// 코드 export/식별자 검색
|
|
1206
|
+
for (const f of walkCode(root)) {
|
|
1207
|
+
if (rel(root, f).startsWith('.harness/')) continue;
|
|
1208
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1209
|
+
const lines = text.split('\n');
|
|
1210
|
+
const exportRe = new RegExp(`(?:export\\s+(?:default\\s+)?(?:async\\s+)?(?:function|const|class|let|var)\\s+(\\w*${escapeRegex(query)}\\w*)|class\\s+(\\w*${escapeRegex(query)}\\w*)|<(\\w*${escapeRegex(query)}\\w*)\\b)`, 'i');
|
|
1211
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1212
|
+
if (exportRe.test(lines[i])) matches.push({ source: rel(root, f), line: i + 1, text: lines[i].trim().slice(0, 120) });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
log(`# reuse find: "${query}"`);
|
|
1216
|
+
if (!matches.length) return ok('기존 자원 없음 — 새로 만드는 것이 최선의 선택일 수 있음');
|
|
1217
|
+
log(`${matches.length}개 후보:`);
|
|
1218
|
+
for (const m of matches.slice(0, parseInt(arg('--limit', '20'), 10))) log(`- ${m.source}:${m.line} ${m.text}`);
|
|
1219
|
+
log(`\n💡 새로 만들기 전에 위 자원을 재사용/확장 가능한지 확인하세요.`);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function reuseRegister(root, name) {
|
|
1223
|
+
if (!name) return fail('name required (e.g., reuse register Card --where components/card.html --kind component --note "기본 카드")');
|
|
1224
|
+
root = absRoot(root);
|
|
1225
|
+
const where = arg('--where', '?');
|
|
1226
|
+
const kind = arg('--kind', 'component');
|
|
1227
|
+
const note = arg('--note', '-');
|
|
1228
|
+
const file = reuseMapPath(root);
|
|
1229
|
+
const text = exists(file) ? read(file) : '';
|
|
1230
|
+
if (text.includes(`| ${name} |`)) return warn(`already registered: ${name}`);
|
|
1231
|
+
const newRow = `| ${name} | ${where} | ${kind} | ${note} |`;
|
|
1232
|
+
const lines = text.split('\n');
|
|
1233
|
+
const headerIdx = lines.findIndex(l => /^\|\s*-+\s*\|/.test(l));
|
|
1234
|
+
if (headerIdx >= 0) {
|
|
1235
|
+
lines.splice(headerIdx + 1, 0, newRow);
|
|
1236
|
+
writeUtf8(file, lines.join('\n'));
|
|
1237
|
+
} else {
|
|
1238
|
+
append(file, '\n' + newRow + '\n');
|
|
1239
|
+
}
|
|
1240
|
+
ok(`reuse registered: ${name} (${kind}) → ${where}`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function uiConsistency(root) {
|
|
1244
|
+
root = absRoot(root);
|
|
1245
|
+
// 1) design-system.md에서 토큰 값 추출
|
|
1246
|
+
const ds = exists(designSystemPath(root)) ? read(designSystemPath(root)) : '';
|
|
1247
|
+
const tokens = {};
|
|
1248
|
+
for (const line of ds.split('\n')) {
|
|
1249
|
+
const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
|
|
1250
|
+
if (!m) continue;
|
|
1251
|
+
const key = m[1].trim();
|
|
1252
|
+
const val = m[2].trim();
|
|
1253
|
+
if (key === 'Token' || /^-+$/.test(key) || val === 'Value' || /실제 값으로 업데이트/.test(val) || !val) continue;
|
|
1254
|
+
tokens[key] = val;
|
|
1255
|
+
}
|
|
1256
|
+
const tokenSet = new Set(Object.values(tokens).map(v => v.toLowerCase()));
|
|
1257
|
+
if (Object.keys(tokens).length === 0) {
|
|
1258
|
+
warn('design-system.md에 토큰이 등록되지 않음 (Tokens 표를 채우면 일관성 검사 가능)');
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
ok(`등록된 디자인 토큰: ${Object.keys(tokens).length}개`);
|
|
1262
|
+
const findings = [];
|
|
1263
|
+
for (const f of walkCode(root)) {
|
|
1264
|
+
if (rel(root, f).startsWith('.harness/')) continue;
|
|
1265
|
+
if (!/\.(css|scss|sass|less|html|jsx|tsx|vue|svelte|js|ts)$/i.test(f)) continue;
|
|
1266
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1267
|
+
const hexes = [...text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)];
|
|
1268
|
+
for (const h of hexes) {
|
|
1269
|
+
const v = h[0].toLowerCase();
|
|
1270
|
+
if (!tokenSet.has(v)) {
|
|
1271
|
+
const line = text.slice(0, h.index).split('\n').length;
|
|
1272
|
+
findings.push({ file: rel(root, f), line, value: h[0], type: 'hex' });
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
// px/rem 휴리스틱은 false positive가 많아 옵션
|
|
1276
|
+
if (has('--strict')) {
|
|
1277
|
+
const sizes = [...text.matchAll(/\b(\d+)(px|rem)\b/g)];
|
|
1278
|
+
for (const s of sizes) {
|
|
1279
|
+
const v = `${s[1]}${s[2]}`;
|
|
1280
|
+
if (!tokenSet.has(v)) {
|
|
1281
|
+
const line = text.slice(0, s.index).split('\n').length;
|
|
1282
|
+
findings.push({ file: rel(root, f), line, value: v, type: 'size' });
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (!findings.length) return ok('UI consistency 통과 (모든 색상이 토큰)');
|
|
1288
|
+
warn(`토큰 외 값 ${findings.length}개:`);
|
|
1289
|
+
for (const f of findings.slice(0, 30)) log(` ${f.file}:${f.line} ${f.value} (${f.type})`);
|
|
1290
|
+
if (findings.length > 30) log(` ... +${findings.length - 30}개`);
|
|
1291
|
+
// 1.9.4 B: cross-platform 종료 코드 명시
|
|
1292
|
+
if (has('--fail-on-violation')) { process.exitCode = 1; if (has('--strict-exit')) process.exit(1); }
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function graphCmd(root) {
|
|
1296
|
+
root = absRoot(root);
|
|
1297
|
+
const edges = [];
|
|
1298
|
+
for (const f of walkCode(root)) {
|
|
1299
|
+
if (rel(root, f).startsWith('.harness/')) continue;
|
|
1300
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1301
|
+
const re = /(?:import\s+[^;\n]*?from\s+['"]|require\(['"]|@import\s+['"]|href=["']|src=["'])([^'")\s]+)/g;
|
|
1302
|
+
let m;
|
|
1303
|
+
while ((m = re.exec(text))) {
|
|
1304
|
+
edges.push({ src: rel(root, f), dst: m[1] });
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const out = arg('--out', null);
|
|
1308
|
+
const lines = ['```mermaid', 'graph TD'];
|
|
1309
|
+
const nodeSet = new Set();
|
|
1310
|
+
for (const e of edges) { nodeSet.add(e.src); nodeSet.add(e.dst); }
|
|
1311
|
+
for (const e of edges) lines.push(` "${e.src}" --> "${e.dst}"`);
|
|
1312
|
+
lines.push('```');
|
|
1313
|
+
const md = `# Code dependency graph\n\n생성: ${now()}\n노드: ${nodeSet.size}, 엣지: ${edges.length}\n\n` + lines.join('\n') + '\n';
|
|
1314
|
+
if (out) {
|
|
1315
|
+
writeUtf8(path.resolve(root, out), md);
|
|
1316
|
+
ok(`graph 저장: ${out}`);
|
|
1317
|
+
} else {
|
|
1318
|
+
log(md);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function guideCmd(root, target) {
|
|
1323
|
+
root = absRoot(root);
|
|
1324
|
+
log(`# 변경 전 가이드 ${target ? `(target: ${target})` : ''}`);
|
|
1325
|
+
log(`Date: ${today()}\n`);
|
|
1326
|
+
if (target) {
|
|
1327
|
+
log('## 1. Impact — 변경하면 영향받는 파일');
|
|
1328
|
+
impactCmd(root, target);
|
|
1329
|
+
log('');
|
|
1330
|
+
}
|
|
1331
|
+
log('## 2. Reuse — 기존 자원 검색');
|
|
1332
|
+
const q = target ? path.basename(target, path.extname(target)) : arg('--query', '');
|
|
1333
|
+
if (q) reuseFind(root, q);
|
|
1334
|
+
else log('(target 또는 --query 없음 — reuse 검색 스킵)');
|
|
1335
|
+
log('');
|
|
1336
|
+
log('## 3. UI consistency — 디자인 토큰 일치');
|
|
1337
|
+
uiConsistency(root);
|
|
1338
|
+
log('\n💡 다음 단계: 위 결과를 바탕으로 작업 계획을 plan/progress에 기록 후 진행하세요.');
|
|
910
1339
|
}
|
|
911
1340
|
|
|
912
1341
|
// ===== Auto update =====
|
|
@@ -1082,7 +1511,7 @@ function viewworkInstall(root) {
|
|
|
1082
1511
|
}
|
|
1083
1512
|
|
|
1084
1513
|
function help() {
|
|
1085
|
-
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
|
|
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`);
|
|
1086
1515
|
}
|
|
1087
1516
|
|
|
1088
1517
|
async function main() {
|
|
@@ -1107,12 +1536,27 @@ async function main() {
|
|
|
1107
1536
|
if (cmd === 'viewwork' && args[1] === 'install') return viewworkInstall(args[2] || process.cwd());
|
|
1108
1537
|
if (cmd === 'viewwork' && args[1] === 'emit') return viewworkEmit(args[2] || process.cwd(), { action: arg('--action','task'), note: arg('--note',''), agent: arg('--agent','leerness'), tool: arg('--tool','leerness-cli') });
|
|
1109
1538
|
if (cmd === 'route') return route(args[1] || 'planning');
|
|
1110
|
-
if (cmd === 'self' && args[1] === 'check') return selfCheck(absRoot(args[2] || process.cwd()));
|
|
1539
|
+
if (cmd === 'self' && args[1] === 'check') return await selfCheck(absRoot(args[2] || process.cwd()));
|
|
1111
1540
|
if (cmd === 'self' && args[1] === 'migrate') return log('Run: npx --yes leerness@latest migrate . --dry-run, then migrate without --dry-run after review.');
|
|
1112
1541
|
if (cmd === 'readme' && args[1] === 'sync') return readmeCmd(args[2] || process.cwd());
|
|
1113
1542
|
if (cmd === 'consistency' && args[1] === 'check') return consistencyCheck(args[2] || process.cwd());
|
|
1114
1543
|
if (cmd === 'consistency' && args[1] === 'merge-design-guide') return mergeDesign(args[2] || process.cwd());
|
|
1115
|
-
if (cmd === 'skill' && args[1] === 'list')
|
|
1544
|
+
if (cmd === 'skill' && args[1] === 'list') return skillList(args[2] || arg('--path', process.cwd()));
|
|
1545
|
+
if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2], absRoot(arg('--path', process.cwd())));
|
|
1546
|
+
if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1547
|
+
if (cmd === 'skill' && args[1] === 'learn') return skillLearn(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1548
|
+
if (cmd === 'skill' && args[1] === 'use') return skillUse(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1549
|
+
if (cmd === 'skill' && args[1] === 'optimize') return skillOptimize(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1550
|
+
if (cmd === 'skill' && args[1] === 'remove') return skillRemove(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1551
|
+
if (cmd === 'skill' && args[1] === 'consolidate') return skillConsolidate(absRoot(arg('--path', process.cwd())));
|
|
1552
|
+
if (cmd === 'gate') return gate(args[1] || process.cwd());
|
|
1553
|
+
if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
|
|
1554
|
+
if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
|
|
1555
|
+
if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
|
|
1556
|
+
if (cmd === 'ui' && args[1] === 'consistency') return uiConsistency(args[2] || process.cwd());
|
|
1557
|
+
if (cmd === 'graph') return graphCmd(args[1] || process.cwd());
|
|
1558
|
+
if (cmd === 'guide') return guideCmd(arg('--path', process.cwd()), args[1]);
|
|
1559
|
+
// legacy duplicate routing removed below (was: skill list/info/add)
|
|
1116
1560
|
if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2]);
|
|
1117
1561
|
if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1118
1562
|
if (cmd === 'plan') {
|
|
@@ -1130,8 +1574,12 @@ async function main() {
|
|
|
1130
1574
|
if (sub==='add') return taskAdd(root, args.slice(2).join(' ') || '새 작업');
|
|
1131
1575
|
if (sub==='update') return taskUpdate(root, args[2]);
|
|
1132
1576
|
if (sub==='drop') return taskDrop(root, args[2]);
|
|
1577
|
+
if (sub==='fix-evidence') return taskFixEvidence(root);
|
|
1133
1578
|
}
|
|
1134
1579
|
return help();
|
|
1135
1580
|
}
|
|
1136
1581
|
|
|
1137
|
-
|
|
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
package/scripts/e2e.js
CHANGED
|
@@ -149,6 +149,160 @@ run('route planning', ['route', 'planning']);
|
|
|
149
149
|
run('route bugfix', ['route', 'bugfix']);
|
|
150
150
|
run('skill list', ['skill', 'list']);
|
|
151
151
|
run('skill info', ['skill', 'info', 'office']);
|
|
152
|
+
|
|
153
|
+
// 1.9.2: 스킬 학습 사이클 회귀
|
|
154
|
+
run('skill learn (new)', ['skill', 'learn', 'open-meteo', '--doc', 'https://open-meteo.com/en/docs', '--command', 'fetch hourly+daily JSON', '--capability', 'http fetch', '--capability', 'cache', '--note', 'e2e learn', '--display', 'Open-Meteo 날씨 스킬', '--path', tmp]);
|
|
155
|
+
run('skill use (new)', ['skill', 'use', 'open-meteo', '--note', 'first call', '--path', tmp]);
|
|
156
|
+
run('skill use (catalog)', ['skill', 'use', 'office', '--note', 'catalog skill materialize', '--path', tmp]);
|
|
157
|
+
run('skill optimize', ['skill', 'optimize', 'open-meteo', '--before', 'no cache', '--after', 'If-Modified-Since', '--note', 'e2e opt', '--path', tmp]);
|
|
158
|
+
|
|
159
|
+
total++;
|
|
160
|
+
{
|
|
161
|
+
const f = path.join(tmp, '.harness/skills/open-meteo/skill.json');
|
|
162
|
+
const ok = fs.existsSync(f) && JSON.parse(fs.readFileSync(f, 'utf8')).optimizations.length === 1;
|
|
163
|
+
console.log(ok ? '✓ skill.json optimizations 누적' : '✗ skill.json optimizations 누적 실패');
|
|
164
|
+
if (!ok) failed++;
|
|
165
|
+
}
|
|
166
|
+
total++;
|
|
167
|
+
{
|
|
168
|
+
const data = JSON.parse(fs.readFileSync(path.join(tmp, '.harness/skills/open-meteo/skill.json'), 'utf8'));
|
|
169
|
+
const ok = data.usage.count === 1 && data.sources.length >= 1 && data.patterns.length >= 1;
|
|
170
|
+
console.log(ok ? '✓ skill usage/sources/patterns 누적' : '✗ skill usage/sources/patterns 누적 실패');
|
|
171
|
+
if (!ok) failed++;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
run('skill consolidate', ['skill', 'consolidate', '--threshold', '0.1', '--path', tmp]);
|
|
175
|
+
run('skill remove (user)', ['skill', 'remove', 'open-meteo', '--path', tmp]);
|
|
176
|
+
total++;
|
|
177
|
+
{
|
|
178
|
+
const ok = !fs.existsSync(path.join(tmp, '.harness/skills/open-meteo'));
|
|
179
|
+
console.log(ok ? '✓ skill remove: 디렉토리 삭제' : '✗ skill remove: 디렉토리 잔존');
|
|
180
|
+
if (!ok) failed++;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 1.9.3 회귀: impact / reuse / ui consistency / graph / guide
|
|
184
|
+
// 가짜 페이지/컴포넌트/스타일 작성
|
|
185
|
+
fs.mkdirSync(path.join(tmp, 'src/components'), { recursive: true });
|
|
186
|
+
fs.mkdirSync(path.join(tmp, 'src/pages'), { recursive: true });
|
|
187
|
+
fs.mkdirSync(path.join(tmp, 'src/styles'), { recursive: true });
|
|
188
|
+
fs.writeFileSync(path.join(tmp, 'src/styles/tokens.css'), `:root { --color-primary: #ff5722; --color-text: #222222; }\n`);
|
|
189
|
+
fs.writeFileSync(path.join(tmp, 'src/components/Card.html'), `<div class="card"><slot/></div>\n`);
|
|
190
|
+
fs.writeFileSync(path.join(tmp, 'src/pages/home.html'), `<link href="../styles/tokens.css"><include src="../components/Card.html"/>\n`);
|
|
191
|
+
fs.writeFileSync(path.join(tmp, 'src/pages/about.html'), `<link href="../styles/tokens.css"><include src="../components/Card.html"/>\n`);
|
|
192
|
+
// design-system 토큰 채우기 (#ff5722 / #222222 등록)
|
|
193
|
+
const dsPath = path.join(tmp, '.harness/design-system.md');
|
|
194
|
+
let dsText = fs.readFileSync(dsPath, 'utf8');
|
|
195
|
+
dsText = dsText.replace('| color.primary | (실제 값으로 업데이트) | |', '| color.primary | #ff5722 | 메인 컬러 |');
|
|
196
|
+
dsText = dsText.replace('| color.surface | | |', '| color.surface | #222222 | 본문 텍스트 |');
|
|
197
|
+
fs.writeFileSync(dsPath, dsText);
|
|
198
|
+
|
|
199
|
+
run('impact: Card.html → home/about', ['impact', 'src/components/Card.html', '--path', tmp]);
|
|
200
|
+
run('reuse find: Card 후보', ['reuse', 'find', 'Card', '--path', tmp]);
|
|
201
|
+
run('reuse register: Card', ['reuse', 'register', 'Card', '--where', 'src/components/Card.html', '--kind', 'component', '--note', 'e2e card', '--path', tmp]);
|
|
202
|
+
|
|
203
|
+
total++;
|
|
204
|
+
{
|
|
205
|
+
const reuse = fs.readFileSync(path.join(tmp, '.harness/reuse-map.md'), 'utf8');
|
|
206
|
+
const ok = /\| Card \| src\/components\/Card\.html \| component \|/.test(reuse);
|
|
207
|
+
console.log(ok ? '✓ reuse-map.md에 Card row 자동 추가' : '✗ Card row 미등록');
|
|
208
|
+
if (!ok) failed++;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
run('ui consistency: pass (토큰 외 색상 없음)', ['ui', 'consistency', tmp]);
|
|
212
|
+
|
|
213
|
+
// 의도적 일탈: 토큰 외 색상 추가 → ui consistency 경고
|
|
214
|
+
fs.writeFileSync(path.join(tmp, 'src/pages/contact.html'), `<style>.box { color: #abcdef; background: #123456; }</style>\n`);
|
|
215
|
+
total++;
|
|
216
|
+
{
|
|
217
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'ui', 'consistency', tmp, '--fail-on-violation'], { encoding: 'utf8' });
|
|
218
|
+
const ok = r.status === 1 && /토큰 외 값/.test(r.stdout) && /#abcdef/i.test(r.stdout);
|
|
219
|
+
console.log(ok ? '✓ ui consistency: 일탈 색상 #abcdef 검출 + 비0 종료' : '✗ ui consistency 일탈 미검출');
|
|
220
|
+
if (!ok) { failed++; console.log(r.stdout); }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
run('graph: mermaid 출력', ['graph', tmp]);
|
|
224
|
+
run('guide: 통합 가이드', ['guide', 'src/components/Card.html', '--path', tmp]);
|
|
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
|
+
|
|
303
|
+
run('gate (all checks)', ['gate', tmp]);
|
|
304
|
+
|
|
305
|
+
run('self check (= update --check)', ['self', 'check', tmp], { });
|
|
152
306
|
run('readme sync', ['readme', 'sync', tmp]);
|
|
153
307
|
run('consistency check', ['consistency', 'check', tmp]);
|
|
154
308
|
run('--version', ['--version']);
|