leerness 1.36.98 → 1.36.99

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.99 — 2026-08-05 — 시안을 빈 종이가 아니라 이 앱의 재료로 시작시킨다 (P-0012)
4
+
5
+ 사용자 지적: *"mockup.html 로 미리보기 시안의 정확도가 높지 않은데 — 개발하는 프로젝트 언어 등을 참고해서, 수정하려는 페이지나 요소를 복제해 높은 정확도의 시안을 보여줄 수 있나?"*
6
+
7
+ 원인은 코드에 그대로 있었다. 종전 스캐폴드는 점선 박스 하나에 *"AI 는 이 영역을 실제 레이아웃으로 교체하세요"* 라고 적어 둔 게 전부였고, 프로젝트의 스택도 기존 컴포넌트도 디자인 토큰도 읽지 않았다. **AI 의 상상에 100% 의존하니 정확할 수가 없었다.**
8
+
9
+ 시안을 **이 앱의 재료**로 시작시킨다.
10
+ - **실제 CSS 변수를 `:root` 에 심는다** — 시안이 앱과 같은 색·간격으로 그려진다. `var(--brand-500)` 이 진짜 값을 갖는다.
11
+ - **이미 있는 컴포넌트를 어휘로 준다** — "같은 걸 또 만들지 마세요" 와 함께 파일 경로까지. 새로 만들기 전에 조립하게 한다.
12
+ - **`--target <파일>` 로 대상 화면을 지정하면** 그 화면이 실제로 쓰는 클래스와 요소를 싣는다. 소스에서 읽은 것이지 렌더 결과가 아니라는 한계도 시안에 함께 적는다(파서 없이 0-의존성으로 할 수 있는 정직한 상한이다). 루트 밖 경로는 읽지 않는다.
13
+ - 자주 쓰는 유틸 어휘를 빈도순으로 준다 — 새 간격·새 색을 발명하지 말라는 근거가 된다.
14
+
15
+ **브라우저로 열어 보고 진짜 결함을 하나 찾았다.** 토큰 목록에 `--panel`·`--mut`·`--txt` 가 있었는데, 이건 사용자 앱의 토큰이 아니라 **leerness 자신이 그 저장소에 만들어 둔 대시보드(`leerness.html`)의 CSS 변수**였다. 시안이 존재하지도 않는 토큰을 쓰라고 지시할 뻔했다. 자기 산출물(`leerness.html`·`roadmap.html`·`leerness-library.html`·시안 파일)을 인벤토리에서 제외한다.
16
+
17
+ 실 프로젝트 영향이 작지 않았다 — leerness 가 설치된 프로젝트마다 유령 토큰이 3~10개씩 있었다: auto-influencer **5 → 0**(전부 가짜였다) · view-work 39 → 29 · Adzento 57 → 47 · leernessIDE 79 → 76. 1.36.98 CHANGELOG 에 *"Tailwind 프로젝트는 CSS 변수가 0~5개"* 라고 적었는데 그 5가 이것이었다. 방향은 같지만 실제 값은 0 이다 — 여기서 정정한다.
18
+
19
+ 가드: selftest 343 · 변이 3/3(자기산출물 제외) · e2e 절제 6/6. 절제 1차에서 자기산출물 케이스가 살아남았는데 **픽스처에 `leerness.html` 이 없어 단언이 공허하게 통과**한 것이었다(e2e 는 대시보드 자동생성을 끄고 돈다) — 픽스처에 명시적으로 넣어 되살렸다.
20
+
3
21
  ## 1.36.98 — 2026-08-04 — session close 가 사용자 문서를 지우고 있었다 + 재사용 인벤토리 (P-0013)
4
22
 
5
23
  ### 🚨 데이터 손실 수정 — `session close` 가 `current-state.md` 를 통째로 갈아치웠다
package/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.98 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.99 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.98는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.99는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.98는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.98 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.99 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.98: 2026-08-04
238
+ Last synced by Leerness v1.36.99: 2026-08-04
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.98';
37
+ const VERSION = '1.36.99';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -7575,6 +7575,10 @@ function _p0013LibraryOk() {
7575
7575
  W('src/Hero.astro', '<section class="p-4 rounded-lg bg-brand-500"></section>\n');
7576
7576
  W('src/theme.css', ':root { --brand-500: #2563eb; --radius-lg: 12px; }\n');
7577
7577
  W('src/inline.html', '<style>:root{--surface-1:#fff}</style><div class="px-3 py-1 rounded-lg"></div>\n');
7578
+ // leerness 가 사용자 저장소에 만들어 두는 산출물은 '이 앱의 것' 이 아니다 — 실측에서 대시보드 변수가 사용자 토큰으로 실렸고,
7579
+ // 시안이 존재하지 않는 토큰(var(--panel))을 쓰라고 지시할 뻔했다.
7580
+ W('leerness.html', '<style>:root{--panel:#111;--mut:#888}</style><div class="px-3 py-1 rounded-lg">dash</div>\n');
7581
+ W('roadmap.html', '<style>:root{--roadmapOnly:#222}</style>\n');
7578
7582
  // CSS 문맥 밖의 `--이름:` 은 토큰이 아니다 — 실측에서 주석 속 CLI 플래그(audit --fix: …)가 디자인 토큰으로 실렸다.
7579
7583
  W('src/docs.ts', '// audit --fix: 누락 키 자동 추가\n// health --strict: 모든 issue 시 exit 1\nexport const runFix = () => 1;\n');
7580
7584
  // 문자열·주석의 자리표시자는 태그처럼 생겼다 — 실측에서 UI 를 그리지 않는 감시 클래스 4개가 이것 때문에 컴포넌트로 실렸다.
@@ -7598,7 +7602,8 @@ function _p0013LibraryOk() {
7598
7602
  const brand = r.tokens.cssVars.find(v => v.name === 'brand-500');
7599
7603
  const tokOk = varNames.includes('brand-500') && varNames.includes('radius-lg') && !!brand && /#2563eb/.test(brand.value)
7600
7604
  && varNames.includes('surface-1') // <style> 블록 안도 CSS 문맥이다
7601
- && !varNames.includes('fix') && !varNames.includes('strict'); // 주석 속 CLI 플래그는 토큰이 아니다
7605
+ && !varNames.includes('fix') && !varNames.includes('strict') // 주석 속 CLI 플래그는 토큰이 아니다
7606
+ && !varNames.includes('panel') && !varNames.includes('mut') && !varNames.includes('roadmapOnly'); // 자기 산출물(leerness.html/roadmap.html)은 남의 것으로 세지 않는다
7602
7607
  // 빈도 문턱: 3회 이상만 싣는다(1~2회는 우연) — 문턱을 지우면 tracking-wide 가 섞인다
7603
7608
  const utilNames = r.tokens.utilities.map(u => u.name);
7604
7609
  const utilOk = utilNames.includes('rounded-lg') && utilNames.includes('bg-brand-500') && !utilNames.includes('tracking-wide');
package/lib/clarify.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // 기록하는 스토어(.harness/previews.json). 승인 전 코드 작성 금지가 지시 레이어 계약.
6
6
  'use strict';
7
7
  const path = require('path');
8
+ const fs = require('fs'); // 1.36.99 (P-0012): 대상 화면 크기 확인(statSync) — 큰 파일은 시안 재료에서 제외
8
9
  const { absRoot, exists, read, writeUtf8, log, ok, warn, fail, failJson, now, today } = require('./io');
9
10
 
10
11
  // ── ① 모호성 신호 카탈로그 (kind → { re, q(match) }) ──────────────────────────
@@ -91,8 +92,62 @@ function _isDesignWork(text) { return DESIGN_WORK_RE.test(String(text || '')); }
91
92
  function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
92
93
 
93
94
  // 자립형 HTML 시안 스캐폴드 — AI 가 섹션을 채워 사용자에게 브라우저로 제시. 0-deps·오프라인(외부 리소스 없음).
94
- function _mockupScaffold(p) {
95
+ // 1.36.99 (P-0012): 시안 정확도. 사용자 지적 — "mockup.html 로 미리보기 시안의 정확도가 높지 않다".
96
+ // 원인은 분명했다: 종전 스캐폴드는 점선 박스 하나에 "AI 는 이 영역을 실제 레이아웃으로 교체하세요" 라고 적어 둔 게 전부라
97
+ // 프로젝트의 스택도, 기존 컴포넌트도, 디자인 토큰도 읽지 않았다 — AI 의 상상에 100% 의존하니 정확할 수가 없다.
98
+ // 그래서 시안을 '빈 종이' 가 아니라 **이 앱의 재료** 로 시작시킨다: 실제 CSS 변수를 :root 에 심고,
99
+ // 이미 있는 컴포넌트 이름을 어휘로 주고, 대상 화면이 지정되면 그 화면이 실제로 쓰는 클래스를 보여준다.
100
+ // (L3 라이브 DOM 캡처는 playwright 옵트인이라 별도 — 0-의존성 경계를 지킨다.)
101
+ function _mockupContext(root, target) {
102
+ const out = { stack: [], tokens: { cssVars: [], utilities: [] }, components: [], target: null, notes: [] };
103
+ try {
104
+ const lib = require('./library');
105
+ const r = lib.scanLibrary(root);
106
+ out.stack = r.stack; out.notes = r.notes;
107
+ out.tokens.cssVars = r.tokens.cssVars.slice(0, 40);
108
+ out.tokens.utilities = r.tokens.utilities.slice(0, 40);
109
+ out.components = r.components.slice(0, 60);
110
+ } catch (e) { out.notes.push(`인벤토리 수집 실패: ${e.message}`); }
111
+ if (target) out.target = _targetSkeleton(root, target);
112
+ return out;
113
+ }
114
+ // 대상 화면이 '실제로' 쓰는 요소/클래스를 뽑는다. 파서 없이(0-의존성) 할 수 있는 건 여기까지이고,
115
+ // 그 한계를 시안에도 그대로 적는다 — 렌더 결과가 아니라 소스에서 읽은 것이다.
116
+ function _targetSkeleton(root, rel) {
117
+ const p = path.resolve(absRoot(root), rel);
118
+ const base = absRoot(root);
119
+ if (p !== base && !p.startsWith(base + path.sep)) return { file: rel, error: '프로젝트 루트 밖 경로는 읽지 않는다' };
120
+ if (!exists(p)) return { file: rel, error: '파일 없음' };
121
+ let txt = '';
122
+ try { const st = fs.statSync(p); if (st.size > 512 * 1024) return { file: rel, error: '파일이 커서 건너뜀(512KB 초과)' }; txt = read(p); } catch (e) { return { file: rel, error: e.message }; }
123
+ const classes = []; const seen = new Set();
124
+ const CLS = /class(?:Name)?\s*=\s*(?:["'`]([^"'`]{1,400})["'`]|\{\s*["'`]([^"'`]{1,400})["'`]\s*\})/g;
125
+ let m; while ((m = CLS.exec(txt))) { const v = (m[1] || m[2] || '').trim(); if (v && !seen.has(v)) { seen.add(v); classes.push(v); } }
126
+ const tags = []; const tseen = new Set();
127
+ const TAG = /(?:^|[\s(={,;:?[])<([A-Za-z][\w.-]*)/g;
128
+ while ((m = TAG.exec(txt))) { const t = m[1]; if (!tseen.has(t)) { tseen.add(t); tags.push(t); } }
129
+ return { file: rel, classes: classes.slice(0, 40), tags: tags.slice(0, 40), lines: txt.split('\n').length };
130
+ }
131
+ function _mockupScaffold(p, ctx) {
95
132
  const feats = (p.features || []).map(f => ` <li>${_esc(f)}</li>`).join('\n');
133
+ const c = ctx || { stack: [], tokens: { cssVars: [], utilities: [] }, components: [], target: null, notes: [] };
134
+ // 실제 CSS 변수를 그대로 심는다 — 시안이 이 앱의 색/간격으로 그려지도록.
135
+ const COLORISH = /^(?:#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/;
136
+ const varDecl = (c.tokens.cssVars || []).map(v => ` --${v.name}: ${String(v.value).replace(/[<>{};]/g, '')};`).join('\n');
137
+ const varChips = (c.tokens.cssVars || []).slice(0, 24).map(v => {
138
+ const sw = COLORISH.test(String(v.value).trim()) ? `<i class="sw" style="background:var(--${_esc(v.name)})"></i>` : '';
139
+ return `<li>${sw}<code>--${_esc(v.name)}</code></li>`;
140
+ }).join('\n');
141
+ const utilChips = (c.tokens.utilities || []).slice(0, 30).map(u => `<li><code>${_esc(u.name)}</code><span class="n">${u.uses}</span></li>`).join('\n');
142
+ const compChips = (c.components || []).map(x => `<li><code>${_esc(x.name)}</code><span class="n">${_esc(x.file)}</span></li>`).join('\n');
143
+ const tgt = c.target;
144
+ const tgtBlock = !tgt ? '' : (tgt.error
145
+ ? `<section class="mock"><h2>대상 화면</h2><p class="notes">${_esc(tgt.file)} — ${_esc(tgt.error)}</p></section>`
146
+ : `<section class="mock"><h2>대상 화면 — ${_esc(tgt.file)} (${tgt.lines}줄)</h2>
147
+ <p class="notes">이 화면이 <b>실제로 쓰는</b> 클래스와 요소다(소스에서 읽음 — 렌더 결과가 아니다). 새 시안은 여기서 벗어나지 않게 그린다.</p>
148
+ <ul class="chips">${(tgt.classes || []).map(x => `<li><code>${_esc(x.slice(0, 90))}</code></li>`).join('')}</ul>
149
+ <ul class="chips">${(tgt.tags || []).map(x => `<li><code>&lt;${_esc(x)}&gt;</code></li>`).join('')}</ul></section>`);
150
+ const notes = (c.notes || []).map(n => `<li>${_esc(n)}</li>`).join('\n');
96
151
  return `<!doctype html>
97
152
  <html lang="ko">
98
153
  <head>
@@ -100,6 +155,10 @@ function _mockupScaffold(p) {
100
155
  <meta name="viewport" content="width=device-width, initial-scale=1">
101
156
  <title>${_esc(p.id)} 시안 — ${_esc(p.title)}</title>
102
157
  <style>
158
+ /* 이 프로젝트에서 실제로 쓰이는 디자인 토큰 — 시안이 앱과 같은 색/간격으로 그려지게 심는다 */
159
+ :root {
160
+ ${varDecl}
161
+ }
103
162
  :root { --ink:#1a1a1a; --sub:#666; --line:#e5e5e5; --accent:#2563eb; --bg:#fafafa; }
104
163
  * { box-sizing:border-box; margin:0; }
105
164
  body { font-family:system-ui,-apple-system,'Segoe UI',sans-serif; color:var(--ink); background:var(--bg); }
@@ -112,6 +171,12 @@ function _mockupScaffold(p) {
112
171
  .placeholder { border:2px dashed #cbd5e1; border-radius:8px; padding:40px; text-align:center; color:#94a3b8; font-size:14px; }
113
172
  .notes { font-size:13px; color:var(--sub); line-height:1.7; }
114
173
  .notes li { margin-left:18px; }
174
+ ul.chips { list-style:none; margin:6px 0 0; padding:0; display:flex; flex-wrap:wrap; gap:5px; }
175
+ ul.chips li { background:#f6f7f9; border:1px solid var(--line); border-radius:6px; padding:3px 7px; font-size:12px; display:flex; align-items:center; gap:5px; }
176
+ ul.chips code { font-family:ui-monospace,Menlo,monospace; }
177
+ ul.chips .n { color:var(--sub); font-size:10.5px; }
178
+ .sw { width:12px; height:12px; border-radius:3px; border:1px solid var(--line); display:inline-block; }
179
+ .stack { font-size:12px; color:var(--sub); }
115
180
  </style>
116
181
  </head>
117
182
  <body>
@@ -127,9 +192,18 @@ function _mockupScaffold(p) {
127
192
  ${feats || ' <li>(없음 — --features "a,b" 로 기록)</li>'}
128
193
  </ul>
129
194
  </section>
195
+ ${tgtBlock}
196
+ <section class="mock">
197
+ <h2>이 앱의 재료 — 새로 만들지 말고 여기서 조립</h2>
198
+ <p class="stack">스택: ${_esc((c.stack || []).join(' · ') || '(미상)')}</p>
199
+ ${compChips ? `<p class="notes" style="margin-top:10px">이미 있는 컴포넌트 ${(c.components || []).length}개 — 같은 걸 또 만들지 마세요</p><ul class="chips">${compChips}</ul>` : '<p class="notes">추출된 컴포넌트 없음 — 아래 안내를 확인하세요</p>'}
200
+ ${varChips ? `<p class="notes" style="margin-top:10px">디자인 토큰(CSS 변수) — 위 :root 에 실제 값으로 심어 뒀으니 <code>var(--이름)</code> 으로 쓰세요</p><ul class="chips">${varChips}</ul>` : ''}
201
+ ${utilChips ? `<p class="notes" style="margin-top:10px">실제로 자주 쓰는 유틸 클래스(빈도순) — 이 어휘 안에서 그리세요</p><ul class="chips">${utilChips}</ul>` : ''}
202
+ ${notes ? `<p class="notes" style="margin-top:10px">인벤토리 한계</p><ul class="notes">${notes}</ul>` : ''}
203
+ </section>
130
204
  <section class="mock">
131
205
  <h2>화면 시안 — ⬇ AI 는 이 영역을 실제 레이아웃 초안으로 교체하세요</h2>
132
- <div class="placeholder">여기에 헤더 / 히어로 / 본문 / 푸터 실제 화면 구조의 HTML/CSS 초안을 그립니다.<br>외부 리소스 없이(오프라인) 이 파일 하나로 열리게 유지하세요.</div>
206
+ <div class="placeholder">위의 <b>실제 토큰·컴포넌트·대상 화면 클래스</b>를 재료로 삼아 그립니다 색/새 간격을 발명하지 마세요.<br>외부 리소스 없이(오프라인) 이 파일 하나로 열리게 유지하세요.</div>
133
207
  </section>
134
208
  </main>
135
209
  <!-- leerness:mockup ${_esc(p.id)} — 이 파일은 시안입니다. 승인(leerness preview approve ${_esc(p.id)}) 전 실제 코드 작성 금지. -->
@@ -228,14 +302,21 @@ function previewCmd(root, sub, rest, deps = {}) {
228
302
  ok(`시안 이미 존재: ${path.relative(root, mkFile)} — 내용 보호를 위해 덮어쓰지 않음 (재생성: --force) · 경로는 기록됨`);
229
303
  return;
230
304
  }
231
- writeUtf8(mkFile, _mockupScaffold(p));
305
+ // 1.36.99 (P-0012): 빈 스캐폴드 대신 이 앱의 재료(토큰·컴포넌트·대상 화면 클래스)를 심어 준다.
306
+ // --target <파일> 을 주면 그 화면이 실제로 쓰는 클래스/요소까지 함께 싣는다.
307
+ const _target = (arg && arg('--target', null)) || null;
308
+ if (_target) p.target = _target;
309
+ const _ctx = _mockupContext(root, p.target || null);
310
+ writeUtf8(mkFile, _mockupScaffold(p, _ctx));
232
311
  p.mockupPath = path.relative(root, mkFile).replace(/\\/g, '/');
233
312
  // (검수 Low) 재생성은 별도 이벤트로 — 이력 오해 방지
234
313
  (p.history = p.history || []).push({ at: now(), event: _regen ? 'mockup-regenerated' : 'mockup-created' });
235
314
  if (!_savePreviews(root, list, json)) return;
236
315
  if (json) { log(JSON.stringify({ ok: true, id: p.id, mockupPath: p.mockupPath, created: !_regen, regenerated: _regen }, null, 2)); return; }
237
316
  ok(`시안 ${_regen ? '재생성' : '스캐폴드 생성'}: ${p.mockupPath}`);
238
- log(` AI: placeholder 영역을 실제 레이아웃 초안(HTML/CSS)으로 교체하세요 (외부 리소스 없이).`);
317
+ log(` 재료: 컴포넌트 ${(_ctx.components || []).length} · CSS변수 ${(_ctx.tokens.cssVars || []).length} · 유틸 ${(_ctx.tokens.utilities || []).length}${_ctx.target ? ` · 대상 ${_ctx.target.file}` : ''}`);
318
+ if (!_ctx.target) log(` → 대상 화면을 지정하면 정확도가 올라갑니다: leerness preview mockup ${p.id} --target src/pages/Foo.tsx --force`);
319
+ log(` → AI: placeholder 영역을 실제 레이아웃 초안(HTML/CSS)으로 교체하세요 (외부 리소스 없이, 위 재료로 조립).`);
239
320
  log(` → 사용자에게 이 파일을 브라우저로 열어 보여주고 승인/수정을 질문으로 받으세요.`);
240
321
  log(` ⓘ 계약: approve 전에는 실제 기능 코드를 작성하지 않는다.`);
241
322
  return;
package/lib/library.js CHANGED
@@ -21,6 +21,10 @@ const SKIP_DIR_RE = /^(?:node_modules|\.git|\.next|_next|dist|build|out|coverage
21
21
  const UI_EXT_RE = /\.(?:jsx|tsx|vue|svelte|astro|js|mjs|cjs|ts|html?|css|scss|less)$/i;
22
22
  const TEST_RE = /(^|[\\/])(?:test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+|[^\\/]+\.stories\.[a-z]+)$|(^|[\\/])(?:tests?|__tests__)[\\/]/i;
23
23
  const MINIFIED_RE = /[.-]min\.[a-z]+$|\.bundle\.[a-z]+$|-[0-9a-f]{8,}\.[a-z]+$/i;
24
+ // leerness 가 사용자 저장소에 만들어 두는 산출물은 '이 앱의 것' 이 아니다 — 실측에서 leerness.html 의 대시보드 변수
25
+ // (--panel · --mut · --txt …)가 사용자 디자인 토큰으로 실렸고, 시안이 존재하지 않는 토큰을 쓰라고 지시할 뻔했다.
26
+ // 자기가 만든 것을 남의 것으로 세지 않는다.
27
+ const OWN_ARTIFACT_RE = /(^|[\\/])(?:leerness|roadmap|leerness-library|leerness-preview[^\\/]*)\.html$/i;
24
28
  const FILE_CAP = 1024 * 1024; // 파일당
25
29
  const TOTAL_CAP = 48 * 1024 * 1024; // 스캔 1회당 — 비용은 파일 하나가 아니라 호출 하나에 묶는다(1.36.97 교훈)
26
30
  const MAX_FILES = 6000;
@@ -74,7 +78,7 @@ function _walk(root, opts) {
74
78
  if (e.isSymbolicLink()) continue; // 링크는 따라가지 않는다(밖으로 새는 유일한 경로)
75
79
  if (e.isDirectory()) { if (!SKIP_DIR_RE.test(e.name)) rec(p, depth + 1); continue; }
76
80
  if (!e.isFile() || !UI_EXT_RE.test(e.name)) continue;
77
- if (TEST_RE.test(p) || MINIFIED_RE.test(e.name)) { stats.skippedGenerated++; continue; }
81
+ if (TEST_RE.test(p) || MINIFIED_RE.test(e.name) || OWN_ARTIFACT_RE.test(p)) { stats.skippedGenerated++; continue; }
78
82
  let st; try { st = fs.statSync(p); } catch { continue; }
79
83
  if (st.size > fileCap) { stats.skippedLarge++; continue; }
80
84
  if (st.size > budget) { stats.budgetExhausted = true; continue; }
@@ -294,4 +298,4 @@ function libraryCmd(root, sub, deps = {}) {
294
298
  log(' 사람용 페이지: leerness library page · 에이전트용 압축: leerness library --ai');
295
299
  }
296
300
 
297
- module.exports = { scanLibrary, compactLibrary, renderLibraryPage, libraryCmd, _walk, _components, _tokens, _cssSources, _hasMarkupNear, _tokenSource, _esc, SKIP_DIR_RE, UI_EXT_RE, AI_CAPS };
301
+ module.exports = { scanLibrary, compactLibrary, renderLibraryPage, libraryCmd, _walk, _components, _tokens, _cssSources, _hasMarkupNear, _tokenSource, _esc, SKIP_DIR_RE, UI_EXT_RE, OWN_ARTIFACT_RE, AI_CAPS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.98",
3
+ "version": "1.36.99",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -9528,6 +9528,45 @@ total++;
9528
9528
  if (!ok) failed++;
9529
9529
  }
9530
9530
 
9531
+ // ── 1.36.99 (P-0012): 시안 정확도 — 시안이 '빈 종이' 가 아니라 이 앱의 재료로 시작하는가.
9532
+ {
9533
+ total++;
9534
+ let ok = false; const dbg = {};
9535
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mk99-'));
9536
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9537
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9538
+ try {
9539
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'shop', version: '0.1.0', dependencies: { react: '18', next: '14', tailwindcss: '3' } }));
9540
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9541
+ const W = (rel, t) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, t); };
9542
+ W('src/theme.css', ':root { --brand-500: #2563eb; --surface-1: #0f1115; }\n');
9543
+ W('src/components/Button.tsx', 'export function Button(){ return <button className="px-3 py-1 rounded-lg bg-brand-500">go</button> }\n');
9544
+ W('src/components/Card.tsx', 'export function Card(){ return <div className="p-4 rounded-lg bg-surface-1">x</div> }\n');
9545
+ W('src/pages/Cart.tsx', 'export function Cart(){\n return (\n <main className="p-6 rounded-lg bg-surface-1">\n <h1 className="text-xl tracking-wide">장바구니</h1>\n </main>\n )\n}\n');
9546
+ // 자기 산출물을 픽스처에 명시적으로 둔다 — 없으면 아래 noOwn 단언이 공허하게 통과한다(실제로 그렇게 살아남았다).
9547
+ // e2e 는 LEERNESS_NO_AUTO_ROADMAP=1 로 돌아 대시보드가 자동 생성되지 않으므로 직접 쓴다.
9548
+ W('leerness.html', '<style>:root{--panel:#111;--mut:#888}</style><div class="px-3 py-1 rounded-lg">dash</div>\n');
9549
+ const id = (/(P-\d{4,})/.exec(out(R(['preview', 'add', '장바구니 개편', '--design', '기존 카드 재사용']))) || [])[1];
9550
+ const mk = out(R(['preview', 'mockup', id, '--target', 'src/pages/Cart.tsx']));
9551
+ const html = fs.readFileSync(path.join(d, '.harness', 'previews', id + '-mockup.html'), 'utf8');
9552
+ const materials = /--brand-500:\s*#2563eb/.test(html) && /Button/.test(html) && /Card/.test(html) && /rounded-lg/.test(html) && /tailwind/.test(html);
9553
+ const targeted = /Cart\.tsx/.test(html) && /p-6 rounded-lg bg-surface-1/.test(html) && /&lt;main&gt;/.test(html);
9554
+ // leerness 가 만든 대시보드 변수를 사용자 토큰인 척 싣지 않는다(실측: 실제 프로젝트마다 3~10개 유령 토큰이 있었다)
9555
+ const noOwn = !/--panel\s*:/.test(html) && !/--mut\s*:/.test(html);
9556
+ const offline = !/(?:src|href)\s*=\s*["'](?!#)/i.test(html) && !/<script/i.test(html);
9557
+ // 대상이 루트 밖이면 읽지 않는다
9558
+ R(['preview', 'mockup', id, '--target', '../../etc/passwd', '--force']);
9559
+ const esc = fs.readFileSync(path.join(d, '.harness', 'previews', id + '-mockup.html'), 'utf8');
9560
+ const guarded = /루트 밖/.test(esc);
9561
+ const reports = /재료: 컴포넌트 \d+/.test(mk);
9562
+ Object.assign(dbg, { materials, targeted, noOwn, offline, guarded, reports, len: html.length });
9563
+ ok = materials && targeted && noOwn && offline && guarded && reports;
9564
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9565
+ console.log(ok ? '✓ B(1.36.99/P-0012) 시안 정확도: 실제 토큰 :root 주입 + 기존 컴포넌트 어휘 + 대상 화면 실클래스 + 자기산출물 오염 0 + 오프라인 + 루트밖 거부'
9566
+ : '✗ 1.36.99 시안 정확도 실패 ' + JSON.stringify(dbg));
9567
+ if (!ok) failed++;
9568
+ }
9569
+
9531
9570
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
9532
9571
  if (failed > 0) process.exit(1);
9533
9572