leerness 1.9.1 → 1.9.3
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 +43 -0
- package/bin/harness.js +393 -32
- package/package.json +1 -1
- package/scripts/e2e.js +77 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.3 — 2026-05-08
|
|
4
|
+
|
|
5
|
+
이번 릴리스는 "이전 작업과 새 작업의 인과관계·재귀 안내·디자인 일관성"을 자동화합니다.
|
|
6
|
+
|
|
7
|
+
### Added — 인과관계·재사용·일관성
|
|
8
|
+
|
|
9
|
+
- `leerness impact <target>` — 변경 전 영향 분석. `<target>`을 `import/require/href/src/@import/url()`로 참조하는 모든 파일을 단일 패스로 식별.
|
|
10
|
+
- `leerness reuse find <query>` — `reuse-map.md`, `design-system.md`, `feature-contracts.md`, `plan/progress`, 그리고 코드의 export/식별자에서 기존 자원을 통합 검색.
|
|
11
|
+
- `leerness reuse register <name> --where <path> --kind component|hook|util|api [--note ...]` — `reuse-map.md`에 자동 row 추가.
|
|
12
|
+
- `leerness ui consistency [path] [--strict] [--fail-on-violation]` — `design-system.md`의 토큰 표를 파싱해 코드의 hex 색상이 토큰에 등록되어 있는지 검사. `--strict`는 px/rem 사이즈도, `--fail-on-violation`은 비-제로 종료.
|
|
13
|
+
- `leerness graph [path] [--out <file>]` — 의존성 그래프를 mermaid 형식으로 출력하거나 파일로 저장.
|
|
14
|
+
- `leerness guide [target]` — 위 4개를 한 번에 실행하는 변경 전 통합 가이드.
|
|
15
|
+
|
|
16
|
+
### Migration
|
|
17
|
+
|
|
18
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
|
|
19
|
+
|
|
20
|
+
## 1.9.2 — 2026-05-08
|
|
21
|
+
|
|
22
|
+
스킬을 살아 있는 학습 사이클로 끌어올린 릴리스. 동일 API 작업이 반복될 때 기존 패턴을 발견·재사용하고, 더 나은 방법이 생기면 최적화 이력으로 누적합니다.
|
|
23
|
+
|
|
24
|
+
### Added — 스킬 학습 사이클
|
|
25
|
+
|
|
26
|
+
- `leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]`
|
|
27
|
+
- 새 스킬을 `.harness/skills/<id>/skill.json`에 생성하거나, 카탈로그 스킬을 로컬에 materialize.
|
|
28
|
+
- `--doc` / `--capability`는 반복 가능 (n번 적으면 모두 누적).
|
|
29
|
+
- `skill.json` 스키마 확장: `sources[]`, `patterns[]`, `optimizations[]`, `usage{count,lastUsed,lastNote}`.
|
|
30
|
+
- `leerness skill use <id> [--note ...]`: 사용 횟수+1, lastUsed 갱신.
|
|
31
|
+
- `leerness skill optimize <id> --before "..." --after "..." [--note ...]`: 최적화 이력 누적.
|
|
32
|
+
- `leerness skill remove <id>`: 사용자 정의 스킬 삭제 (카탈로그 스킬은 로컬 메타만 정리).
|
|
33
|
+
- `leerness skill consolidate [--threshold 0.3]`: 모든 스킬의 capability 토큰 jaccard 비교로 통합 후보 자동 발견.
|
|
34
|
+
- `leerness skill list`가 카탈로그 + 사용자 스킬을 합쳐 출력 (출처/사용횟수/최종 컬럼 추가).
|
|
35
|
+
- `leerness skill info <id>`가 sources/patterns/optimizations까지 모두 표시.
|
|
36
|
+
|
|
37
|
+
### Added — 게이트 통합
|
|
38
|
+
|
|
39
|
+
- `leerness gate [path]` — `verify + audit + scan secrets + encoding check + lazy detect`을 한번에 실행해 단일 요약을 출력. 한 단계라도 실패하면 비-제로 종료.
|
|
40
|
+
- `leerness self check`을 `leerness update --check`의 thin wrapper로 통합. 단일 출처(npm view + 캐시)로 일원화하면서 1.8.0과의 호환을 위해 명령 자체는 유지.
|
|
41
|
+
|
|
42
|
+
### Migration
|
|
43
|
+
|
|
44
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. 카탈로그 스킬에 대한 사용 기록은 처음 `skill use`/`skill optimize` 시점부터 누적되기 시작합니다.
|
|
45
|
+
|
|
3
46
|
## 1.9.1 — 2026-05-08
|
|
4
47
|
|
|
5
48
|
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.3';
|
|
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');
|
|
@@ -900,13 +1035,224 @@ function mergeDesign(root) {
|
|
|
900
1035
|
ok(merged ? 'design guides merged into .harness/design-system.md' : 'nothing to merge');
|
|
901
1036
|
}
|
|
902
1037
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1038
|
+
// 1.9.2: self check를 update --check의 thin wrapper로 통합 (단일 출처).
|
|
1039
|
+
async function selfCheck(root) {
|
|
1040
|
+
return await updateCmd(root, { checkOnly: true });
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// 1.9.2: 게이트 5종 한번에 실행 (verify + audit + scan secrets + encoding check + lazy detect).
|
|
1044
|
+
function gate(root) {
|
|
1045
|
+
root = absRoot(root);
|
|
1046
|
+
log('# leerness gate (5 checks)');
|
|
1047
|
+
let bad = 0;
|
|
1048
|
+
function step(label, fn) {
|
|
1049
|
+
log(`\n## ${label}`);
|
|
1050
|
+
const code0 = process.exitCode || 0;
|
|
1051
|
+
try { fn(); } catch (e) { fail(`${label} threw: ${e.message}`); bad++; }
|
|
1052
|
+
if (process.exitCode && process.exitCode !== code0) bad++;
|
|
1053
|
+
process.exitCode = 0;
|
|
1054
|
+
}
|
|
1055
|
+
step('verify', () => verify(root));
|
|
1056
|
+
step('audit', () => audit(root));
|
|
1057
|
+
step('scan secrets', () => scanSecrets(root));
|
|
1058
|
+
step('encoding check', () => encodingCheck(root));
|
|
1059
|
+
step('lazy detect', () => lazyDetect(root));
|
|
1060
|
+
log(`\n# gate summary: ${bad} 단계 실패`);
|
|
1061
|
+
if (bad) process.exitCode = 1;
|
|
1062
|
+
else ok('all gates passed');
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ===== 1.9.3: Causal / reuse / consistency =====
|
|
1066
|
+
const CODE_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.css','.scss','.sass','.less','.html','.htm','.vue','.svelte','.md','.json','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php']);
|
|
1067
|
+
function* walkCode(root, base = root, depth = 0) {
|
|
1068
|
+
if (depth > 12) return;
|
|
1069
|
+
for (const e of fs.readdirSync(base, { withFileTypes: true })) {
|
|
1070
|
+
const p = path.join(base, e.name);
|
|
1071
|
+
const r = path.relative(root, p).replace(/\\/g, '/');
|
|
1072
|
+
if (Array.from(SCAN_SKIP_DIRS).some(d => r === d || r.startsWith(d + '/'))) continue;
|
|
1073
|
+
if (e.isDirectory()) yield* walkCode(root, p, depth + 1);
|
|
1074
|
+
else if (CODE_EXT.has(path.extname(p).toLowerCase())) yield p;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
function escapeRegex(s) { return String(s).replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); }
|
|
1078
|
+
|
|
1079
|
+
function impactCmd(root, target) {
|
|
1080
|
+
if (!target) return fail('target required (e.g., impact src/components/card.html)');
|
|
1081
|
+
root = absRoot(root);
|
|
1082
|
+
const abs = path.isAbsolute(target) ? target : path.resolve(root, target);
|
|
1083
|
+
const base = path.basename(target);
|
|
1084
|
+
const noext = path.basename(target, path.extname(target));
|
|
1085
|
+
const direct = [];
|
|
1086
|
+
const targetRel = rel(root, abs);
|
|
1087
|
+
for (const f of walkCode(root)) {
|
|
1088
|
+
if (path.resolve(f) === path.resolve(abs)) continue;
|
|
1089
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1090
|
+
const patterns = [
|
|
1091
|
+
new RegExp(`(?:import\\s+[^;\\n]*?from\\s+['"]|require\\(['"]|@import\\s+['"]|href=["']|src=["']|url\\(\\s*['"]?|include\\(['"]?)[^'")\\s]*${escapeRegex(base)}`),
|
|
1092
|
+
// bare reference to filename without extension (e.g., 'card' alias)
|
|
1093
|
+
new RegExp(`['"][^'"\\n]*${escapeRegex(noext)}\\b[^'"\\n]*['"]`)
|
|
1094
|
+
];
|
|
1095
|
+
if (patterns.some(re => re.test(text))) direct.push(rel(root, f));
|
|
1096
|
+
}
|
|
1097
|
+
log(`# impact: ${targetRel}`);
|
|
1098
|
+
if (direct.length === 0) ok('영향 범위 없음 (참조하는 파일 없음)');
|
|
1099
|
+
else {
|
|
1100
|
+
log(`참조하는 파일 ${direct.length}개:`);
|
|
1101
|
+
direct.forEach(d => log('- ' + d));
|
|
1102
|
+
}
|
|
1103
|
+
return { target: targetRel, direct };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function reuseMapPath(root) { return path.join(root, '.harness/reuse-map.md'); }
|
|
1107
|
+
function designSystemPath(root) { return path.join(root, '.harness/design-system.md'); }
|
|
1108
|
+
function featureContractsPath(root) { return path.join(root, '.harness/feature-contracts.md'); }
|
|
1109
|
+
|
|
1110
|
+
function reuseFind(root, query) {
|
|
1111
|
+
if (!query) return fail('query required');
|
|
1112
|
+
root = absRoot(root);
|
|
1113
|
+
const re = new RegExp(escapeRegex(query), 'i');
|
|
1114
|
+
const matches = [];
|
|
1115
|
+
for (const src of [reuseMapPath(root), designSystemPath(root), featureContractsPath(root), planPath(root), progressPath(root)]) {
|
|
1116
|
+
if (!exists(src)) continue;
|
|
1117
|
+
const text = read(src);
|
|
1118
|
+
const lines = text.split('\n');
|
|
1119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1120
|
+
if (re.test(lines[i])) matches.push({ source: rel(root, src), line: i + 1, text: lines[i].trim() });
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
// 코드 export/식별자 검색
|
|
1124
|
+
for (const f of walkCode(root)) {
|
|
1125
|
+
if (rel(root, f).startsWith('.harness/')) continue;
|
|
1126
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1127
|
+
const lines = text.split('\n');
|
|
1128
|
+
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');
|
|
1129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1130
|
+
if (exportRe.test(lines[i])) matches.push({ source: rel(root, f), line: i + 1, text: lines[i].trim().slice(0, 120) });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
log(`# reuse find: "${query}"`);
|
|
1134
|
+
if (!matches.length) return ok('기존 자원 없음 — 새로 만드는 것이 최선의 선택일 수 있음');
|
|
1135
|
+
log(`${matches.length}개 후보:`);
|
|
1136
|
+
for (const m of matches.slice(0, parseInt(arg('--limit', '20'), 10))) log(`- ${m.source}:${m.line} ${m.text}`);
|
|
1137
|
+
log(`\n💡 새로 만들기 전에 위 자원을 재사용/확장 가능한지 확인하세요.`);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function reuseRegister(root, name) {
|
|
1141
|
+
if (!name) return fail('name required (e.g., reuse register Card --where components/card.html --kind component --note "기본 카드")');
|
|
1142
|
+
root = absRoot(root);
|
|
1143
|
+
const where = arg('--where', '?');
|
|
1144
|
+
const kind = arg('--kind', 'component');
|
|
1145
|
+
const note = arg('--note', '-');
|
|
1146
|
+
const file = reuseMapPath(root);
|
|
1147
|
+
const text = exists(file) ? read(file) : '';
|
|
1148
|
+
if (text.includes(`| ${name} |`)) return warn(`already registered: ${name}`);
|
|
1149
|
+
const newRow = `| ${name} | ${where} | ${kind} | ${note} |`;
|
|
1150
|
+
const lines = text.split('\n');
|
|
1151
|
+
const headerIdx = lines.findIndex(l => /^\|\s*-+\s*\|/.test(l));
|
|
1152
|
+
if (headerIdx >= 0) {
|
|
1153
|
+
lines.splice(headerIdx + 1, 0, newRow);
|
|
1154
|
+
writeUtf8(file, lines.join('\n'));
|
|
1155
|
+
} else {
|
|
1156
|
+
append(file, '\n' + newRow + '\n');
|
|
1157
|
+
}
|
|
1158
|
+
ok(`reuse registered: ${name} (${kind}) → ${where}`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function uiConsistency(root) {
|
|
1162
|
+
root = absRoot(root);
|
|
1163
|
+
// 1) design-system.md에서 토큰 값 추출
|
|
1164
|
+
const ds = exists(designSystemPath(root)) ? read(designSystemPath(root)) : '';
|
|
1165
|
+
const tokens = {};
|
|
1166
|
+
for (const line of ds.split('\n')) {
|
|
1167
|
+
const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
|
|
1168
|
+
if (!m) continue;
|
|
1169
|
+
const key = m[1].trim();
|
|
1170
|
+
const val = m[2].trim();
|
|
1171
|
+
if (key === 'Token' || /^-+$/.test(key) || val === 'Value' || /실제 값으로 업데이트/.test(val) || !val) continue;
|
|
1172
|
+
tokens[key] = val;
|
|
1173
|
+
}
|
|
1174
|
+
const tokenSet = new Set(Object.values(tokens).map(v => v.toLowerCase()));
|
|
1175
|
+
if (Object.keys(tokens).length === 0) {
|
|
1176
|
+
warn('design-system.md에 토큰이 등록되지 않음 (Tokens 표를 채우면 일관성 검사 가능)');
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
ok(`등록된 디자인 토큰: ${Object.keys(tokens).length}개`);
|
|
1180
|
+
const findings = [];
|
|
1181
|
+
for (const f of walkCode(root)) {
|
|
1182
|
+
if (rel(root, f).startsWith('.harness/')) continue;
|
|
1183
|
+
if (!/\.(css|scss|sass|less|html|jsx|tsx|vue|svelte|js|ts)$/i.test(f)) continue;
|
|
1184
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1185
|
+
const hexes = [...text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)];
|
|
1186
|
+
for (const h of hexes) {
|
|
1187
|
+
const v = h[0].toLowerCase();
|
|
1188
|
+
if (!tokenSet.has(v)) {
|
|
1189
|
+
const line = text.slice(0, h.index).split('\n').length;
|
|
1190
|
+
findings.push({ file: rel(root, f), line, value: h[0], type: 'hex' });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
// px/rem 휴리스틱은 false positive가 많아 옵션
|
|
1194
|
+
if (has('--strict')) {
|
|
1195
|
+
const sizes = [...text.matchAll(/\b(\d+)(px|rem)\b/g)];
|
|
1196
|
+
for (const s of sizes) {
|
|
1197
|
+
const v = `${s[1]}${s[2]}`;
|
|
1198
|
+
if (!tokenSet.has(v)) {
|
|
1199
|
+
const line = text.slice(0, s.index).split('\n').length;
|
|
1200
|
+
findings.push({ file: rel(root, f), line, value: v, type: 'size' });
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (!findings.length) return ok('UI consistency 통과 (모든 색상이 토큰)');
|
|
1206
|
+
warn(`토큰 외 값 ${findings.length}개:`);
|
|
1207
|
+
for (const f of findings.slice(0, 30)) log(` ${f.file}:${f.line} ${f.value} (${f.type})`);
|
|
1208
|
+
if (findings.length > 30) log(` ... +${findings.length - 30}개`);
|
|
1209
|
+
if (has('--fail-on-violation')) process.exitCode = 1;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function graphCmd(root) {
|
|
1213
|
+
root = absRoot(root);
|
|
1214
|
+
const edges = [];
|
|
1215
|
+
for (const f of walkCode(root)) {
|
|
1216
|
+
if (rel(root, f).startsWith('.harness/')) continue;
|
|
1217
|
+
let text; try { text = read(f); } catch { continue; }
|
|
1218
|
+
const re = /(?:import\s+[^;\n]*?from\s+['"]|require\(['"]|@import\s+['"]|href=["']|src=["'])([^'")\s]+)/g;
|
|
1219
|
+
let m;
|
|
1220
|
+
while ((m = re.exec(text))) {
|
|
1221
|
+
edges.push({ src: rel(root, f), dst: m[1] });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
const out = arg('--out', null);
|
|
1225
|
+
const lines = ['```mermaid', 'graph TD'];
|
|
1226
|
+
const nodeSet = new Set();
|
|
1227
|
+
for (const e of edges) { nodeSet.add(e.src); nodeSet.add(e.dst); }
|
|
1228
|
+
for (const e of edges) lines.push(` "${e.src}" --> "${e.dst}"`);
|
|
1229
|
+
lines.push('```');
|
|
1230
|
+
const md = `# Code dependency graph\n\n생성: ${now()}\n노드: ${nodeSet.size}, 엣지: ${edges.length}\n\n` + lines.join('\n') + '\n';
|
|
1231
|
+
if (out) {
|
|
1232
|
+
writeUtf8(path.resolve(root, out), md);
|
|
1233
|
+
ok(`graph 저장: ${out}`);
|
|
1234
|
+
} else {
|
|
1235
|
+
log(md);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function guideCmd(root, target) {
|
|
1240
|
+
root = absRoot(root);
|
|
1241
|
+
log(`# 변경 전 가이드 ${target ? `(target: ${target})` : ''}`);
|
|
1242
|
+
log(`Date: ${today()}\n`);
|
|
1243
|
+
if (target) {
|
|
1244
|
+
log('## 1. Impact — 변경하면 영향받는 파일');
|
|
1245
|
+
impactCmd(root, target);
|
|
1246
|
+
log('');
|
|
1247
|
+
}
|
|
1248
|
+
log('## 2. Reuse — 기존 자원 검색');
|
|
1249
|
+
const q = target ? path.basename(target, path.extname(target)) : arg('--query', '');
|
|
1250
|
+
if (q) reuseFind(root, q);
|
|
1251
|
+
else log('(target 또는 --query 없음 — reuse 검색 스킵)');
|
|
1252
|
+
log('');
|
|
1253
|
+
log('## 3. UI consistency — 디자인 토큰 일치');
|
|
1254
|
+
uiConsistency(root);
|
|
1255
|
+
log('\n💡 다음 단계: 위 결과를 바탕으로 작업 계획을 plan/progress에 기록 후 진행하세요.');
|
|
910
1256
|
}
|
|
911
1257
|
|
|
912
1258
|
// ===== Auto update =====
|
|
@@ -1082,7 +1428,7 @@ function viewworkInstall(root) {
|
|
|
1082
1428
|
}
|
|
1083
1429
|
|
|
1084
1430
|
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
|
|
1431
|
+
log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy\n leerness impact <target> # 변경 전 영향 분석\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
|
|
1086
1432
|
}
|
|
1087
1433
|
|
|
1088
1434
|
async function main() {
|
|
@@ -1107,12 +1453,27 @@ async function main() {
|
|
|
1107
1453
|
if (cmd === 'viewwork' && args[1] === 'install') return viewworkInstall(args[2] || process.cwd());
|
|
1108
1454
|
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
1455
|
if (cmd === 'route') return route(args[1] || 'planning');
|
|
1110
|
-
if (cmd === 'self' && args[1] === 'check') return selfCheck(absRoot(args[2] || process.cwd()));
|
|
1456
|
+
if (cmd === 'self' && args[1] === 'check') return await selfCheck(absRoot(args[2] || process.cwd()));
|
|
1111
1457
|
if (cmd === 'self' && args[1] === 'migrate') return log('Run: npx --yes leerness@latest migrate . --dry-run, then migrate without --dry-run after review.');
|
|
1112
1458
|
if (cmd === 'readme' && args[1] === 'sync') return readmeCmd(args[2] || process.cwd());
|
|
1113
1459
|
if (cmd === 'consistency' && args[1] === 'check') return consistencyCheck(args[2] || process.cwd());
|
|
1114
1460
|
if (cmd === 'consistency' && args[1] === 'merge-design-guide') return mergeDesign(args[2] || process.cwd());
|
|
1115
|
-
if (cmd === 'skill' && args[1] === 'list')
|
|
1461
|
+
if (cmd === 'skill' && args[1] === 'list') return skillList(args[2] || arg('--path', process.cwd()));
|
|
1462
|
+
if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2], absRoot(arg('--path', process.cwd())));
|
|
1463
|
+
if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1464
|
+
if (cmd === 'skill' && args[1] === 'learn') return skillLearn(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1465
|
+
if (cmd === 'skill' && args[1] === 'use') return skillUse(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1466
|
+
if (cmd === 'skill' && args[1] === 'optimize') return skillOptimize(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1467
|
+
if (cmd === 'skill' && args[1] === 'remove') return skillRemove(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1468
|
+
if (cmd === 'skill' && args[1] === 'consolidate') return skillConsolidate(absRoot(arg('--path', process.cwd())));
|
|
1469
|
+
if (cmd === 'gate') return gate(args[1] || process.cwd());
|
|
1470
|
+
if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
|
|
1471
|
+
if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
|
|
1472
|
+
if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
|
|
1473
|
+
if (cmd === 'ui' && args[1] === 'consistency') return uiConsistency(args[2] || process.cwd());
|
|
1474
|
+
if (cmd === 'graph') return graphCmd(args[1] || process.cwd());
|
|
1475
|
+
if (cmd === 'guide') return guideCmd(arg('--path', process.cwd()), args[1]);
|
|
1476
|
+
// legacy duplicate routing removed below (was: skill list/info/add)
|
|
1116
1477
|
if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2]);
|
|
1117
1478
|
if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1118
1479
|
if (cmd === 'plan') {
|
package/package.json
CHANGED
package/scripts/e2e.js
CHANGED
|
@@ -149,6 +149,83 @@ 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
|
+
run('gate (all checks)', ['gate', tmp]);
|
|
227
|
+
|
|
228
|
+
run('self check (= update --check)', ['self', 'check', tmp], { });
|
|
152
229
|
run('readme sync', ['readme', 'sync', tmp]);
|
|
153
230
|
run('consistency check', ['consistency', 'check', tmp]);
|
|
154
231
|
run('--version', ['--version']);
|