leerness 1.36.97 → 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,48 @@
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
+
21
+ ## 1.36.98 — 2026-08-04 — session close 가 사용자 문서를 지우고 있었다 + 재사용 인벤토리 (P-0013)
22
+
23
+ ### 🚨 데이터 손실 수정 — `session close` 가 `current-state.md` 를 통째로 갈아치웠다
24
+
25
+ 사용자 보고(다른 프로젝트의 AI 이력): *"session close가 또 current-state.md를 덮어썼습니다. 복원하겠습니다."* — **"또"** 가 핵심이었다.
26
+
27
+ 재현했다. **440자 → 88자, 80% 파괴.** 사용자가 손으로 적어 둔 섹션 두 개(`## 결정 로그`, `## 인수인계 메모`)가 통째로 사라지고 Now/Next/Blockers 의 모든 상세 줄이 한 줄로 대체됐다. 원인은 세 개의 정규식 치환이었고, 마지막 하나가 결정적이었다 — 섹션이 아니라 **`## Blockers` 부터 파일 끝까지**를 지웠다. 그리고 이 명령은 제품이 AGENTS.md 로 *"매 세션 끝에 반드시 실행"* 하라고 지시하는 것이라, 손실이 상시로 일어났다.
28
+
29
+ 고친 방식은 범위를 좁히는 게 아니라 **소유권을 표시하는 것**이다. 도구가 쓴 줄에 `<!-- leerness:auto -->` 표지를 달고 **표지 있는 줄만** 교체한다. 표지 없는 줄은 어떤 경우에도 지우지 않는다. 섹션 범위도 EOF 가 아니라 다음 `##` 제목까지로 닫았다. 실측: 손실 0 · 3회 반복 실행에도 296자로 안정(자동줄·빈줄 누적 없음) · 섹션이 없는 문서는 건드리지 않음 · 표지가 없던 구버전 파일의 마지막 생성 줄 하나는 남지만 누적되지 않는다(사용자 문서를 지우는 것보다 이쪽이 안전한 실패다).
30
+
31
+ 변이 5종 중 4종 사살, 나머지 1종(섹션 끝을 EOF 로 되돌리기)은 **실행해서 등가임을 증명**했다 — 보존이 범위 계산이 아니라 "표지 없는 줄은 전부 유지"에서 나오므로 범위를 틀려도 삭제가 일어나지 않는다. 설계가 옳으면 가드가 한 겹 덜 필요하다.
32
+
33
+ ### 재사용 인벤토리 (P-0013) — `leerness library`
34
+
35
+ `reuse-map` 이 중복 '함수' 를 막는다면 이쪽은 중복 '화면 조각' 을 막는다. 저장소에서 컴포넌트·디자인 토큰을 뽑아 사람용 오프라인 페이지(`library page`)와 에이전트용 압축 JSON(`library --ai`)으로 나눠 낸다. 나누는 게 핵심이다 — 인벤토리 전체를 컨텍스트에 밀어 넣으면 토큰을 아끼려던 기능이 토큰을 더 쓴다(실측: 페이지 23KB vs AI 페이로드 5.3KB).
36
+
37
+ 설계는 실측이 세 번 뒤집었다.
38
+ - **순진한 추출은 2~3배 부풀린다.** 대문자 export 를 세면 auto-influencer 83 · view-work 116 · **플레인 HTML 프로젝트 62** 인데, 그 62개는 전부 가짜다. 없는 것을 있다고 말하는 인벤토리는 없느니만 못하다 → 선언 근처에 **실제 마크업**이 있는 것만 싣는다(같은 프로젝트 39 · 46 · **1**).
39
+ - **창을 고정 문자 수로 자르면 안 된다.** 실측한 선언→마크업 거리는 p50 684 · p90 3,248 · p99 20,639 자라 1,500 자 창은 69% 만 포착했고, 키우면 "파일에 JSX 있으면 전부"라는 부풀린 휴리스틱으로 되돌아간다. **다음 최상위 선언까지**로 잡아 상수를 없앴다(들여쓰기를 허용하면 컴포넌트 첫 줄 `const [s,setS]=useState()` 가 창을 즉시 닫아 반대로 붕괴한다 — 열 0 이 최상위라는 신호다).
40
+ - **토큰 출처가 스택마다 정반대다.** Tailwind 프로젝트(12개 중 7개)는 CSS 커스텀 프로퍼티가 0~5개인 대신 유틸 어휘가 74~121종이고(`text-ink-50` 417회 — 빈도가 곧 사실상의 토큰), 안 쓰는 프로젝트는 CSS 변수 58개에 유틸 0. 한쪽만 보면 절반이 '토큰 없음' 이 된다.
41
+
42
+ 만들면서 오탐 세 종을 실측으로 잡았다: TypeScript 제네릭(`Promise<Response>`)이 JSX 태그와 글자가 같아 zod 스키마가 컴포넌트로 실렸고, 문자열·주석의 자리표시자(`<svc>`·`<uuid>`·`<browserPort>`)가 UI 를 그리지 않는 감시 클래스 4개를 컴포넌트로 만들었으며, `--이름:` 을 파일 전체에서 찾다가 주석 속 CLI 플래그(`audit --fix:`)가 '디자인 토큰' 으로 실렸다(이 저장소에서 57개 중 43개가 그것이었다). 각각 위치·속성·문맥으로 갈랐다. 아무것도 못 찾으면 빈 페이지를 그럴듯하게 내지 않고 이유를 말한다.
43
+
44
+ 부수적으로 **UI 스택 감지**를 `tech-profile` 에 추가했다(react/next/vue/svelte/astro/tailwind/vite/django 등). 새 판정기를 만들지 않고 기존 한 곳을 확장했다 — 표면마다 따로 판정하면 반드시 어긋난다(1.36.97 에서 도메인 목록으로 같은 실수를 했다). 실측 12개 프로젝트 12/12 일치. `.html` 을 확장자 집계에 넣으면 **"html" 이 언어로 등재**되고, `changed` 판정에서 ui 를 빼면 기존 프로필을 가진 프로젝트에 ui 가 **영영 기록되지 않는다** — 둘 다 셀프테스트로 못박았다.
45
+
3
46
  ## 1.36.97 — 2026-08-04 — 가장 많이 물리는 축을 가장 얕게 묻고 있었다 (P-0011)
4
47
 
5
48
  사용자 질문: *"leerness가 설치된 프로젝트에서 작업할 때 이 개념들(11개 공통 축 · 20개 분야)이 적용되는지 확인하고, 적용할 가치가 있는지 판단해줘."*
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.97 하네스를 사용합니다. 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.97는 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.97는 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.97 릴리스 태그 이력** (수백 라운드) · _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.97: 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.97';
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') 시 호스트 프로세스 오염.
@@ -6153,7 +6153,29 @@ function _selfTestCases() {
6153
6153
  && tps.includes('prev = null') && tps.includes('_mergeManifests(root, 2)')
6154
6154
  && io2.includes('!_jsonErrEmitted && t && (t[0]') && /process\.on\('exit', \(code\) => \{ if \(code !== 0/.test(io2) && io2.includes('.corrupt-${Date.now()}-${process.pid}-')
6155
6155
  && s.includes('declaredTestCount != null && runResult && !runResult.skipped && runResult.allPassed');
6156
- return clar && shape && noDbUrl && reqOk && guards;
6156
+ // 1.36.98 (P-0013): UI 스택 감지 라이브러리 인벤토리/시안 생성기가 스택별 전략을 고르는 근거라 오판이 곧 잘못된 산출물이 된다.
6157
+ // 실측(실제 12개 프로젝트)에서 Tailwind 프로젝트는 CSS 변수가 0~5개뿐이고 유틸 어휘가 74~121종이라 토큰 출처가 정반대다.
6158
+ const tmp3 = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_ui98_'));
6159
+ let uiOk = false;
6160
+ try {
6161
+ const mk = (sub, files) => { const d = path.join(tmp3, sub); fs.mkdirSync(path.join(d, '.harness'), { recursive: true }); for (const [n, c] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(d, n)), { recursive: true }); fs.writeFileSync(path.join(d, n), c); } return d; };
6162
+ const idsOf = (d) => (tp.detectTechProfile(d).ui || []).map(u => u.id);
6163
+ const langsOf = (d) => tp.detectTechProfile(d).languages.map(l => l.id);
6164
+ const nextApp = mk('nextapp', { 'package.json': JSON.stringify({ dependencies: { next: '14', react: '18', tailwindcss: '3' } }), 'index.html': '<html></html>' });
6165
+ const plain = mk('plain', { 'package.json': JSON.stringify({ dependencies: {} }), 'a.html': '<html></html>', 'b.html': '<html></html>', 'app.js': 'const x=1' });
6166
+ const nA = idsOf(nextApp), nP = idsOf(plain);
6167
+ uiOk = ['next', 'react', 'tailwind'].every(k => nA.includes(k))
6168
+ && !nA.includes('plain-html') // 프레임워크가 있으면 index.html 이 있어도 플레인 아님
6169
+ && nP.includes('plain-html') && nP.length === 1 // 신호 0 + html 존재 → 플레인
6170
+ && !langsOf(plain).includes('html') && !langsOf(plain).some(l => l[0] === '#') // html 이 '언어' 로 새지 않는다
6171
+ && Array.isArray(tp.detectTechProfile(plain).ui);
6172
+ // ui 만 바뀌어도 프로필이 다시 기록되는가 — 빼먹으면 기존 프로젝트에 ui 가 영영 안 남는다
6173
+ const detA = tp.detectTechProfile(nextApp);
6174
+ fs.writeFileSync(path.join(nextApp, '.harness', 'tech-profile.json'), JSON.stringify({ current: { languages: detA.languages, services: detA.services }, updatedAt: 'x', history: [] }));
6175
+ uiOk = uiOk && tp.refreshTechProfile(nextApp).changed === true
6176
+ && (JSON.parse(read(path.join(nextApp, '.harness', 'tech-profile.json'))).current.ui || []).some(u => u.id === 'tailwind');
6177
+ } catch { uiOk = false; } finally { try { fs.rmSync(tmp3, { recursive: true, force: true }); } catch {} }
6178
+ return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved();
6157
6179
  } },
6158
6180
  { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
6159
6181
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
@@ -7528,6 +7550,101 @@ const LENS_CATALOG = {
7528
7550
  };
7529
7551
  // 1.36.97 (P-0011): 도메인 목록을 세 표면(help ko · help en · commands)에 각각 손으로 적어 두었더니, 축을 추가할 때마다
7530
7552
  // 세 곳을 동시에 고쳐야 했고 한 곳만 놓쳐도 낡은 목록이 계속 광고된다. 카탈로그에서 파생시켜 어긋날 수 없게 한다.
7553
+ // 1.36.98 (P-0013): 라이브러리 추출기 행위 가드. 실측이 잡은 오탐/누락을 그대로 케이스로 박는다 —
7554
+ // ① zod 스키마가 컴포넌트로 잡혔다(TS 제네릭 `Array<string>` 이 JSX 태그와 글자가 같아서)
7555
+ // ② 창을 고정 문자 수로 자르면 30% 를 놓치고, 들여쓰기된 선언을 경계로 삼으면 반대로 붕괴한다
7556
+ // ③ 플레인 HTML 프로젝트에 순진하게 돌리면 가짜 컴포넌트가 쏟아진다(실측 62개)
7557
+ function _p0013LibraryOk() {
7558
+ const os = require('os');
7559
+ const lib = require('../lib/library');
7560
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lib98_'));
7561
+ try {
7562
+ const W = (rel, txt) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, txt); };
7563
+ fs.mkdirSync(path.join(d, '.harness'), { recursive: true });
7564
+ W('src/Button.tsx', 'import React from "react";\nexport function Button({ label }) {\n const [on, setOn] = React.useState(false);\n const cls = "px-3 py-1 rounded-lg bg-brand-500";\n return (\n <button className="px-3 py-1 rounded-lg bg-brand-500" onClick={() => setOn(!on)}>{label}</button>\n );\n}\n');
7565
+ // 제네릭은 '실제로 JSX 태그와 같은 글자' 인 것을 써야 판별된다 — Record<string, number> 는 원래 안 잡혀서 대조군이 못 된다.
7566
+ W('src/schemas.ts', 'import { z } from "zod";\nexport const UserSchema = z.object({ id: z.string() });\nexport const FetchUser = async (id: string): Promise<Response> => fetch(id);\nexport const LIMITS: Array<number> = [1, 2];\n');
7567
+ W('src/consts.ts', 'export const API_URL = "https://x";\nexport const MAX_RETRY = 3;\n');
7568
+ // 대문자 상수인데 자기 창 안에 진짜 마크업이 있는 경우 — 상수 필터가 없으면 이게 '컴포넌트' 로 실린다.
7569
+ W('src/empty-state.tsx', 'export const EMPTY_STATE = <div className="px-3 py-1 rounded-lg">Nothing</div>;\n');
7570
+ // 선언에서 마크업까지 1,500자를 넘는 진짜 컴포넌트 — 창을 고정 길이로 되돌리면 이게 사라진다(실측 p90 = 3,248자).
7571
+ // 괄호 주의: .repeat 이 줄 전체에 걸려야 한다(마지막 조각에만 걸면 235자밖에 안 돼 이 케이스가 무의미해진다).
7572
+ W('src/LongForm.tsx', 'export function LongForm(props) {\n' + (' const pad = "' + 'x'.repeat(60) + '";\n').repeat(40) + ' return <form className="px-3 py-1 rounded-lg">{props.children}</form>;\n}\n');
7573
+ W('src/Card.vue', '<template><div class="p-4 rounded-lg bg-brand-500" /></template>\n');
7574
+ W('src/page.astro', '<html></html>\n');
7575
+ W('src/Hero.astro', '<section class="p-4 rounded-lg bg-brand-500"></section>\n');
7576
+ W('src/theme.css', ':root { --brand-500: #2563eb; --radius-lg: 12px; }\n');
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');
7582
+ // CSS 문맥 밖의 `--이름:` 은 토큰이 아니다 — 실측에서 주석 속 CLI 플래그(audit --fix: …)가 디자인 토큰으로 실렸다.
7583
+ W('src/docs.ts', '// audit --fix: 누락 키 자동 추가\n// health --strict: 모든 issue 시 exit 1\nexport const runFix = () => 1;\n');
7584
+ // 문자열·주석의 자리표시자는 태그처럼 생겼다 — 실측에서 UI 를 그리지 않는 감시 클래스 4개가 이것 때문에 컴포넌트로 실렸다.
7585
+ W('src/Watcher.ts', 'export class SessionWatcher {\n url = "ws://host:<svc>/probe";\n // path: /sessions/<uuid>/events — usage: watch <path> --json\n}\n');
7586
+ // 반대로 속성이 있으면 소문자 요소도 진짜 마크업이다(자리표시자와의 판별선)
7587
+ W('src/Glyph.tsx', 'export const Glyph = wrap(<path d="M0 0h4" className="rounded-lg" />);\n');
7588
+ W('src/rare.tsx', 'export function Rare() { return <i className="tracking-wide" />; }\n');
7589
+ W('node_modules/pkg/Evil.tsx', 'export function Evil() { return <div className="px-3 py-1 rounded-lg bg-brand-500" />; }\n');
7590
+ W('src/Button.test.tsx', 'export function TestOnly() { return <div className="px-3 py-1 rounded-lg bg-brand-500" />; }\n');
7591
+ const r = lib.scanLibrary(d);
7592
+ const names = r.components.map(c => c.name);
7593
+ const compOk = names.includes('Button') && names.includes('Card') && names.includes('Hero') && names.includes('Rare')
7594
+ && names.includes('LongForm') // 선언→마크업이 멀어도 놓치지 않는다
7595
+ && !names.includes('UserSchema') && !names.includes('FetchUser') && !names.includes('LIMITS') // TS 제네릭(Promise<Response>)을 JSX 로 오인하지 않는다
7596
+ && !names.includes('API_URL') && !names.includes('MAX_RETRY') && !names.includes('EMPTY_STATE') // 대문자 상수는 마크업을 담고 있어도 컴포넌트가 아니다
7597
+ && names.includes('Glyph') // 속성 있는 소문자 요소는 진짜 마크업
7598
+ && !names.includes('SessionWatcher') // 문자열 속 <svc>/<uuid>/<path> 자리표시자는 마크업이 아니다
7599
+ && !names.includes('page') // 소문자 .astro 는 페이지
7600
+ && !names.includes('Evil') && !names.includes('TestOnly'); // 벤더·테스트는 인벤토리 밖
7601
+ const varNames = r.tokens.cssVars.map(v => v.name);
7602
+ const brand = r.tokens.cssVars.find(v => v.name === 'brand-500');
7603
+ const tokOk = varNames.includes('brand-500') && varNames.includes('radius-lg') && !!brand && /#2563eb/.test(brand.value)
7604
+ && varNames.includes('surface-1') // <style> 블록 안도 CSS 문맥이다
7605
+ && !varNames.includes('fix') && !varNames.includes('strict') // 주석 속 CLI 플래그는 토큰이 아니다
7606
+ && !varNames.includes('panel') && !varNames.includes('mut') && !varNames.includes('roadmapOnly'); // 자기 산출물(leerness.html/roadmap.html)은 남의 것으로 세지 않는다
7607
+ // 빈도 문턱: 3회 이상만 싣는다(1~2회는 우연) — 문턱을 지우면 tracking-wide 가 섞인다
7608
+ const utilNames = r.tokens.utilities.map(u => u.name);
7609
+ const utilOk = utilNames.includes('rounded-lg') && utilNames.includes('bg-brand-500') && !utilNames.includes('tracking-wide');
7610
+ // 아무것도 못 찾으면 빈 결과를 그럴듯하게 내지 말고 이유를 말한다
7611
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lib98e_'));
7612
+ fs.mkdirSync(path.join(empty, '.harness'), { recursive: true });
7613
+ fs.writeFileSync(path.join(empty, 'readme.txt'), 'no ui here');
7614
+ const er = lib.scanLibrary(empty);
7615
+ // '노트가 하나라도 있다' 로는 부족하다 — 다른 분기가 대신 채우면 통과해 버린다(변이에서 실제로 그랬다).
7616
+ // '스캔된 파일이 0' 이라는 사실을 말하는 노트인지까지 본다.
7617
+ const emptyOk = er.components.length === 0 && er.tokenSource === 'none'
7618
+ && er.stats.scanned === 0 && er.notes.some(n => n.includes('스캔된 UI 파일이 없다'));
7619
+ try { fs.rmSync(empty, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ }
7620
+ return compOk && tokOk && utilOk && emptyOk;
7621
+ } catch { return false; } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7622
+ }
7623
+ // 1.36.98 (사용자 보고, hive-analytics): session close 가 current-state.md 를 통째로 갈아치웠다(재현: 440자 → 88자,
7624
+ // 사용자 작성 섹션 2개 소실). 원인은 `/## Blockers\n[\s\S]*$/` — 섹션이 아니라 '파일 끝까지' 를 교체했다.
7625
+ // 이 명령은 제품이 매 세션 끝에 실행하라고 지시하는 것이라 손실이 상시로 일어났다. 보존을 행위로 못박는다.
7626
+ function _p0088CurrentStatePreserved() {
7627
+ const os = require('os');
7628
+ const sc = require('../lib/session-close');
7629
+ const U = sc._upsertAutoLines;
7630
+ if (typeof U !== 'function') return false;
7631
+ const doc = ['# Current State', 'Updated: 2026-08-01', '', '## Now', '- 사람이 쓴 진행 메모', '- 두 번째 줄', '',
7632
+ '## Next', '- 사람이 쓴 다음 계획', '', '## Blockers', '- 사람이 쓴 차단 사유', '',
7633
+ '## 결정 로그', '- 2026-07-28: 재시도 상한 5회', '', '## 인수인계 메모', '- docs/x.md 부터', ''].join('\n');
7634
+ const once = U(U(U(doc, 'Now', ['- 자동요약']), 'Next', ['- 자동다음']), 'Blockers', ['- (없음)']);
7635
+ const twice = U(U(U(once, 'Now', ['- 자동요약']), 'Next', ['- 자동다음']), 'Blockers', ['- (없음)']);
7636
+ const human = ['사람이 쓴 진행 메모', '두 번째 줄', '사람이 쓴 다음 계획', '사람이 쓴 차단 사유', '## 결정 로그', '2026-07-28: 재시도 상한 5회', '## 인수인계 메모', 'docs/x.md 부터'];
7637
+ const preserved = human.every(l => once.includes(l) && twice.includes(l)); // Blockers 아래 섹션이 살아 있어야 한다
7638
+ const idempotent = once === twice; // 반복 실행에 자동줄/빈줄이 쌓이지 않는다
7639
+ const autoCount = (twice.match(/leerness:auto/g) || []).length === 3;
7640
+ const noBlankRun = !/\n\n\n/.test(twice);
7641
+ const untouched = U('# Current State\n자유 메모만\n', 'Now', ['- x']) === '# Current State\n자유 메모만\n'; // 섹션 없으면 문서를 바꾸지 않는다
7642
+ // 소스 가드: EOF 까지 지우던 '코드' 가 되살아나면 실패.
7643
+ // 패턴 자체를 grep 하면 이 결함을 설명하는 주석에 스스로 걸린다(자기참조 함정) — 옛 코드 형태로 앵커를 잡는다.
7644
+ const src = read(path.join(__dirname, '..', 'lib', 'session-close.js'));
7645
+ const noEofWipe = !src.includes('cs.replace(/## Block' + 'ers');
7646
+ return preserved && idempotent && autoCount && noBlankRun && untouched && noEofWipe;
7647
+ }
7531
7648
  function _lensDomainList() { return Object.keys(LENS_CATALOG).join('|'); }
7532
7649
  // 1.36.97: 완료 검증의 인라인 렌즈 줄은 '목차'다 — 본문이 아니다.
7533
7650
  // 심화 문항은 한 문단이라 통째로 찍으면 80칸에서 한 축이 3~4줄을 먹는다(검수 실측: advisory 블록이 8줄 → 15줄).
@@ -24906,6 +25023,8 @@ async function main() {
24906
25023
  if (cmd === 'toggle') return _tgl.toggleCmd(arg('--path', process.cwd()), args[1], args[2], args[3], { has, VERSION }); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동)
24907
25024
  // 1.36.53 (UR-0062): 기술 프로필 · 1.36.67 (F15): 변경 시 기존 leerness.html 동반 갱신(있을 때만)
24908
25025
  if (cmd === 'tech') return _tech.techCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), args[1], { has, regenGraph: (r) => { if (exists(path.join(r, 'leerness.html'))) _graph.graphHtmlCmd(r, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }); } });
25026
+ // 1.36.98 (P-0013): 재사용 인벤토리 — 새 화면 조각을 만들기 전에 '이미 있는 것' 을 먼저 본다(reuse-map 의 UI 판).
25027
+ if (cmd === 'library') return require('../lib/library').libraryCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), args[1], { has, log, ok, warn, failJson });
24909
25028
  if (cmd === 'integrity') { // 1.36.57 (감사 F-04): managed 문서 무결성 점검/복구
24910
25029
  if (args[1] && args[1] !== 'check') { failJson(has('--json'), 'unknown_subcommand', `알 수 없는 integrity 하위명령: ${args[1]} (가능: check [--repair] [--force])`); return process.exit(process.exitCode || 1); }
24911
25030
  return integrityCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd());
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 ADDED
@@ -0,0 +1,301 @@
1
+ 'use strict';
2
+ // 1.36.98 (P-0013): 재사용 인벤토리 — 저장소에서 '이미 있는' 컴포넌트·요소·디자인 토큰을 뽑아 사람과 AI 가 함께 본다.
3
+ // reuse-map 이 중복 '함수' 를 막는다면 이쪽은 중복 '화면 조각' 을 막는다. 손으로 유지하지 않고 재생성한다.
4
+ //
5
+ // 설계는 실측이 정했다(실제 12개 프로젝트, 읽기 전용):
6
+ // ① 컴포넌트를 '대문자 export' 로 세면 2~3배 부풀려진다(auto-influencer 83→33, view-work 116→66).
7
+ // 플레인 HTML 프로젝트(Adzento)는 62→0 — 순진한 추출기를 돌리면 가짜 컴포넌트 62개짜리 인벤토리가 나온다.
8
+ // 사람도 AI 도 그걸 믿고 참조하므로, 없는 것을 있다고 말하는 산출물은 없느니만 못하다.
9
+ // → 증명 가능한 하한만 싣는다: 선언 근처에 실제 마크업(JSX/템플릿)이 있어야 컴포넌트로 인정한다.
10
+ // ② 토큰 출처가 스택마다 정반대다. Tailwind 를 쓰는 7개는 CSS 커스텀 프로퍼티가 0~5개인 대신 유틸 어휘가 74~121종이고
11
+ // (auto-influencer 의 text-ink-50 은 417회 — 빈도가 곧 '사실상의 토큰' 이라는 증거),
12
+ // Tailwind 를 안 쓰는 Adzento 는 CSS 변수 58개에 유틸 0. 한쪽만 보는 추출기는 절반에서 '토큰 없음' 을 낸다.
13
+ // ③ 아무것도 못 찾으면 빈 페이지를 그럴듯하게 내지 말고 "찾지 못함" 과 그 이유를 말한다.
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+ const { absRoot, exists } = require('./io');
17
+
18
+ // 스캔 경계 — 벤더/빌드 산출물/가상환경은 남의 코드다. 인벤토리에 섞이면 '우리 것' 이라는 전제가 깨진다.
19
+ const SKIP_DIR_RE = /^(?:node_modules|\.git|\.next|_next|dist|build|out|coverage|vendor|\.venv|venv|site-packages|__pycache__|\.cache|target|release\d*|win-unpacked|\.harness|\.svelte-kit|\.nuxt|storybook-static)$/i;
20
+ // .astro 를 빠뜨렸다가 Astro 프로젝트에서 컴포넌트 0 을 냈다(실측: store-aeo-page 는 .astro 16개인데 0). 파일 자체가 컴포넌트인 형식은 함께 센다.
21
+ const UI_EXT_RE = /\.(?:jsx|tsx|vue|svelte|astro|js|mjs|cjs|ts|html?|css|scss|less)$/i;
22
+ const TEST_RE = /(^|[\\/])(?:test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+|[^\\/]+\.stories\.[a-z]+)$|(^|[\\/])(?:tests?|__tests__)[\\/]/i;
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;
28
+ const FILE_CAP = 1024 * 1024; // 파일당
29
+ const TOTAL_CAP = 48 * 1024 * 1024; // 스캔 1회당 — 비용은 파일 하나가 아니라 호출 하나에 묶는다(1.36.97 교훈)
30
+ const MAX_FILES = 6000;
31
+
32
+ // 선언 근처에 마크업이 있는가 — '증명 가능한 하한' 의 판정자.
33
+ // JSX(`return <` / `=> <` / `<Tag ... />`)나 createElement 호출이 창(window) 안에 있어야 컴포넌트로 본다.
34
+ const MARKUP_RE = /return\s*\(?\s*</;
35
+ const MARKUP_ARROW_RE = /=>\s*\(?\s*</;
36
+ const CREATE_EL_RE = /(?:React\.)?createElement\s*\(/;
37
+ // TypeScript 제네릭(Record<string>, useState<number>)은 JSX 태그와 글자가 같다 — 실측에서 zod 스키마가
38
+ // 컴포넌트로 잡혔다(EmojiDensitySchema 등). 차이는 위치다: 제네릭은 '식별자 바로 뒤', JSX 는 '표현식 자리'.
39
+ // 그래서 `<` 앞에 단어 문자가 오면 제네릭으로 보고 버린다.
40
+ // 그리고 문자열·주석의 '자리표시자' 도 태그처럼 생겼다 — 실측에서 `:<svc>` `<uuid>` `<browserPort>` 때문에
41
+ // UI 를 그리지 않는 감시 클래스 4개가 컴포넌트로 실렸다(view-work 50개 중 4개). 진짜 마크업은 둘 중 하나다:
42
+ // 대문자로 시작하는 컴포넌트 태그이거나, 속성을 가진 소문자 요소. 속성 없는 소문자 한 낱말은 자리표시자로 본다.
43
+ const JSX_TAG_RE = /(?:^|[\s(={,;:?[])<(?:[A-Z][\w.]*(?:\s[^<>]{0,200})?\/?>|[a-z][\w-]*\s+[a-zA-Z-]+\s*=[^<>]{0,200}>)/m;
44
+ const DECL_RE = /export\s+(?:default\s+)?(?:function|const|let|class)\s+([A-Za-z_$][\w$]*)/g;
45
+ // 창의 '끝' 은 다음 선언이다 — 고정 문자 수로 자르면 둘 다 틀린다. 실측: 선언→첫 마크업 거리는 p50 684 · p90 3,248 ·
46
+ // p99 20,639 자라, 1,500 자 창은 실제 선언의 69%만 포착했고(누락 30%), 창을 6,000 으로 키우면 사실상 '파일에 JSX 가
47
+ // 있으면 전부' 가 되어 2~3배 부풀리던 옛 휴리스틱으로 되돌아간다. 경계를 구조에서 가져오면 상수가 필요 없다.
48
+ // 경계는 '최상위' 선언만이다 — 들여쓰기를 허용하면 컴포넌트 본문 첫 줄의 `const [s, setS] = useState()` 가
49
+ // 곧바로 창을 닫아 버려(실측: 컴포넌트 수가 7·6·0 으로 붕괴) 반대쪽으로 틀린다. 열 0 이 곧 최상위라는 신호다.
50
+ const BOUNDARY_RE = /\n(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+[A-Za-z_$][\w$]*/g;
51
+ const ALL_CAPS_RE = /^[A-Z0-9_]+$/;
52
+
53
+ function _hasMarkup(win) {
54
+ return MARKUP_RE.test(win) || MARKUP_ARROW_RE.test(win) || CREATE_EL_RE.test(win) || JSX_TAG_RE.test(win);
55
+ }
56
+ // 하위호환 + 단위 검증용: 시작점부터 '다음 선언 전까지' 를 본다(끝을 못 찾으면 파일 끝까지).
57
+ function _hasMarkupNear(text, from) {
58
+ BOUNDARY_RE.lastIndex = from + 1;
59
+ const m = BOUNDARY_RE.exec(text);
60
+ return _hasMarkup(text.slice(from, m ? m.index : text.length));
61
+ }
62
+
63
+ // 파일 순회 — root 밖으로 나가지 않고(심볼릭 디렉토리 미추적), 예산 안에서만 읽는다.
64
+ function _walk(root, opts) {
65
+ const cap = (opts && opts.totalCap) || TOTAL_CAP;
66
+ const fileCap = (opts && opts.fileCap) || FILE_CAP;
67
+ const maxFiles = (opts && opts.maxFiles) || MAX_FILES;
68
+ const out = []; const stats = { scanned: 0, skippedLarge: 0, skippedGenerated: 0, budgetExhausted: false };
69
+ let budget = cap;
70
+ const rec = (dir, depth) => {
71
+ if (depth > 10 || out.length >= maxFiles) return;
72
+ let ents = [];
73
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
74
+ for (const e of ents) {
75
+ if (out.length >= maxFiles) return;
76
+ if (e.name.startsWith('.') && e.name !== '.') { if (SKIP_DIR_RE.test(e.name)) continue; }
77
+ const p = path.join(dir, e.name);
78
+ if (e.isSymbolicLink()) continue; // 링크는 따라가지 않는다(밖으로 새는 유일한 경로)
79
+ if (e.isDirectory()) { if (!SKIP_DIR_RE.test(e.name)) rec(p, depth + 1); continue; }
80
+ if (!e.isFile() || !UI_EXT_RE.test(e.name)) continue;
81
+ if (TEST_RE.test(p) || MINIFIED_RE.test(e.name) || OWN_ARTIFACT_RE.test(p)) { stats.skippedGenerated++; continue; }
82
+ let st; try { st = fs.statSync(p); } catch { continue; }
83
+ if (st.size > fileCap) { stats.skippedLarge++; continue; }
84
+ if (st.size > budget) { stats.budgetExhausted = true; continue; }
85
+ let txt; try { txt = fs.readFileSync(p, 'utf8'); } catch { continue; }
86
+ // 기계 생성(한 줄이 지나치게 긴 파일)은 어휘 통계를 오염시킨다
87
+ const lines = txt.split('\n').length;
88
+ if (lines > 0 && txt.length / lines > 400) { stats.skippedGenerated++; continue; }
89
+ budget -= st.size;
90
+ stats.scanned++;
91
+ out.push({ rel: path.relative(root, p).replace(/\\/g, '/'), text: txt });
92
+ }
93
+ };
94
+ rec(root, 0);
95
+ return { files: out, stats };
96
+ }
97
+
98
+ // 컴포넌트 — 스택별로 판정이 다르다. .vue/.svelte 는 파일 자체가 컴포넌트이고, JSX 계열은 선언 근처 마크업을 요구한다.
99
+ function _components(files) {
100
+ const out = []; const seen = new Set();
101
+ const add = (name, rel, line, kind) => { const k = name + '@' + rel; if (!seen.has(k)) { seen.add(k); out.push({ name, file: rel, line, kind }); } };
102
+ for (const f of files) {
103
+ const ext = (f.rel.match(/\.([a-z]+)$/i) || [, ''])[1].toLowerCase();
104
+ if (ext === 'vue' || ext === 'svelte' || ext === 'astro') {
105
+ const base = f.rel.split('/').pop().replace(/\.[a-z]+$/i, '');
106
+ if (/^[A-Z]/.test(base)) add(base, f.rel, 1, ext); // 관례상 대문자 파일명이 컴포넌트(소문자는 페이지·라우트)
107
+ continue;
108
+ }
109
+ if (!/^(?:jsx|tsx|js|mjs|cjs|ts)$/.test(ext)) continue;
110
+ let m; DECL_RE.lastIndex = 0;
111
+ while ((m = DECL_RE.exec(f.text))) {
112
+ const name = m[1];
113
+ if (!/^[A-Z]/.test(name) || ALL_CAPS_RE.test(name)) continue; // 소문자 유틸·대문자 상수(API_URL)는 컴포넌트가 아니다
114
+ if (!_hasMarkupNear(f.text, m.index)) continue; // ← 하한의 핵심: 마크업이 없으면 세지 않는다
115
+ const line = f.text.slice(0, m.index).split('\n').length;
116
+ add(name, f.rel, line, ext);
117
+ }
118
+ }
119
+ return out.sort((a, b) => a.name.localeCompare(b.name));
120
+ }
121
+
122
+ // 디자인 토큰 — 두 출처를 모두 본다. 하나만 보면 스택 절반에서 '토큰 없음' 이 된다.
123
+ const CSSVAR_RE = /--([a-zA-Z][\w-]*)\s*:\s*([^;}\n]{1,80})/g;
124
+ const CLASS_ATTR_RE = /class(?:Name)?\s*=\s*(?:["'`]([^"'`]{1,600})["'`]|\{\s*["'`]([^"'`]{1,600})["'`]\s*\})/g;
125
+ // 유틸 어휘: 색/간격/타이포처럼 '디자인 결정' 을 담은 것만. 레이아웃 플래그(flex, block)는 토큰이 아니라 구조다.
126
+ const UTIL_RE = /\b(?:bg|text|border|ring|from|via|to|fill|stroke|shadow|rounded|font|leading|tracking|gap|p|px|py|pt|pb|pl|pr|m|mx|my|w|h)-[a-z0-9][\w./[\]%-]*/g;
127
+ // CSS 변수는 'CSS 문맥' 에서만 뽑는다. 파일 전체에서 `--이름:` 을 찾으면 주석·문자열의 CLI 플래그가 그대로 들어온다
128
+ // (실측: 이 저장소에서 --json · --apply · --fix · --strict 가 '디자인 토큰' 으로 실렸다 — `audit --fix: 누락 키 자동 추가`).
129
+ // <style> 추출은 정규식 대신 인덱스 탐색으로 한다: 닫는 태그가 없는 입력에서 되돌림이 생기지 않는다(1.36.97 교훈).
130
+ function _cssSources(rel, text) {
131
+ if (/\.(?:css|scss|less)$/i.test(rel)) return [text];
132
+ const out = []; let i = 0;
133
+ for (;;) {
134
+ const s = text.indexOf('<style', i); if (s < 0) break;
135
+ const gt = text.indexOf('>', s); if (gt < 0) break;
136
+ const e = text.indexOf('</style', gt); if (e < 0) break;
137
+ out.push(text.slice(gt + 1, e)); i = e + 7;
138
+ }
139
+ return out;
140
+ }
141
+ function _tokens(files) {
142
+ const cssVars = new Map(); const utils = new Map();
143
+ for (const f of files) {
144
+ for (const css of _cssSources(f.rel, f.text)) {
145
+ let m; CSSVAR_RE.lastIndex = 0;
146
+ while ((m = CSSVAR_RE.exec(css))) {
147
+ const name = m[1], val = m[2].trim();
148
+ const cur = cssVars.get(name);
149
+ if (cur) cur.uses++; else cssVars.set(name, { name, value: val, file: f.rel, uses: 1 });
150
+ }
151
+ }
152
+ let c; CLASS_ATTR_RE.lastIndex = 0;
153
+ while ((c = CLASS_ATTR_RE.exec(f.text))) {
154
+ const cls = c[1] || c[2] || '';
155
+ let u; UTIL_RE.lastIndex = 0;
156
+ while ((u = UTIL_RE.exec(cls))) utils.set(u[0], (utils.get(u[0]) || 0) + 1);
157
+ }
158
+ }
159
+ // 빈도는 '사실상의 토큰' 이라는 증거다 — 1~2회짜리는 우연이므로 싣지 않는다.
160
+ const utilList = [...utils.entries()].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]).map(([name, uses]) => ({ name, uses }));
161
+ return { cssVars: [...cssVars.values()].sort((a, b) => b.uses - a.uses), utilities: utilList };
162
+ }
163
+
164
+ // 스택별 어떤 출처가 '주' 인지 — 인벤토리를 읽는 쪽이 무엇을 믿어야 하는지 알아야 한다.
165
+ function _tokenSource(ui, tokens) {
166
+ const hasTw = ui.includes('tailwind');
167
+ if (hasTw && tokens.utilities.length) return 'tailwind-utilities';
168
+ if (tokens.cssVars.length) return 'css-variables';
169
+ if (tokens.utilities.length) return 'utility-classes';
170
+ return 'none';
171
+ }
172
+
173
+ function scanLibrary(root, opts = {}) {
174
+ root = absRoot(root);
175
+ let ui = [];
176
+ try { ui = (require('./tech-profile').detectTechProfile(root).ui || []).map(u => u.id); } catch { ui = []; }
177
+ const { files, stats } = _walk(root, opts);
178
+ const components = _components(files);
179
+ const tokens = _tokens(files);
180
+ const notes = [];
181
+ if (!stats.scanned) notes.push('스캔된 UI 파일이 없다 — 이 저장소에 화면 코드가 없거나, 전부 제외 규칙(벤더/빌드/테스트/기계생성)에 걸렸다.');
182
+ else if (!components.length) notes.push(`컴포넌트를 찾지 못했다(${stats.scanned}개 파일 스캔). 선언 근처에 마크업이 있는 것만 세므로, 플레인 HTML 프로젝트나 마크업이 템플릿 파일에만 있는 구조에서는 0 이 정상이다.`);
183
+ if (_tokenSource(ui, tokens) === 'none' && stats.scanned) notes.push('디자인 토큰을 찾지 못했다 — CSS 커스텀 프로퍼티도, 3회 이상 쓰인 유틸 어휘도 없다.');
184
+ if (stats.budgetExhausted) notes.push('스캔 예산을 넘겨 일부 파일을 건너뛰었다 — 인벤토리가 완전하지 않다.');
185
+ if (stats.skippedLarge) notes.push(`${stats.skippedLarge}개 파일이 크기 상한을 넘어 제외됐다.`);
186
+ return {
187
+ stack: ui,
188
+ tokenSource: _tokenSource(ui, tokens),
189
+ components,
190
+ tokens,
191
+ stats,
192
+ notes,
193
+ };
194
+ }
195
+
196
+ // ── AI 참조용 압축 뷰. 사람용 HTML 과 분리하는 게 핵심이다 —
197
+ // 인벤토리 전체를 컨텍스트에 밀어 넣으면 '토큰을 아끼려고 만든 기능' 이 토큰을 더 쓴다.
198
+ // 자르는 경우 반드시 자름을 알린다(조용한 절단은 '전부 봤다' 는 거짓 인상을 준다).
199
+ const AI_CAPS = { components: 200, utilities: 60, cssVars: 60 };
200
+ function compactLibrary(r, caps = AI_CAPS) {
201
+ const cut = {};
202
+ const take = (arr, n, key) => { if (arr.length > n) cut[key] = arr.length - n; return arr.slice(0, n); };
203
+ return {
204
+ stack: r.stack,
205
+ tokenSource: r.tokenSource,
206
+ components: take(r.components, caps.components, 'components').map(c => ({ n: c.name, f: `${c.file}:${c.line}` })),
207
+ tokens: {
208
+ utilities: take(r.tokens.utilities, caps.utilities, 'utilities').map(u => `${u.name}×${u.uses}`),
209
+ cssVars: take(r.tokens.cssVars, caps.cssVars, 'cssVars').map(v => v.name),
210
+ },
211
+ counts: { components: r.components.length, utilities: r.tokens.utilities.length, cssVars: r.tokens.cssVars.length, filesScanned: r.stats.scanned },
212
+ truncated: Object.keys(cut).length ? cut : undefined,
213
+ notes: r.notes,
214
+ };
215
+ }
216
+
217
+ const _esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
218
+ // 색으로 보이는 값만 견본을 그린다 — 값 문자열을 그대로 style 에 넣지 않고, 안전한 형식일 때만 통과시킨다.
219
+ const _COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/;
220
+ function renderLibraryPage(r, meta = {}) {
221
+ const rows = r.components.map(c => `<tr><td class="n">${_esc(c.name)}</td><td class="k">${_esc(c.kind)}</td><td class="f">${_esc(c.file)}:${_esc(c.line)}</td></tr>`).join('\n');
222
+ const vars = r.tokens.cssVars.map(v => {
223
+ const sw = _COLOR_RE.test(v.value.trim()) ? `<i class="sw" style="background:${_esc(v.value.trim())}"></i>` : '';
224
+ return `<li>${sw}<code>--${_esc(v.name)}</code> <span class="v">${_esc(v.value)}</span> <span class="u">${v.uses}회</span></li>`;
225
+ }).join('\n');
226
+ const utils = r.tokens.utilities.map(u => `<li><code>${_esc(u.name)}</code> <span class="u">${u.uses}</span></li>`).join('\n');
227
+ const notes = r.notes.map(n => `<li>${_esc(n)}</li>`).join('\n');
228
+ const empty = !r.components.length && !r.tokens.cssVars.length && !r.tokens.utilities.length;
229
+ return `<!doctype html>
230
+ <html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
231
+ <title>${_esc(meta.project || 'project')} — 재사용 인벤토리</title>
232
+ <style>
233
+ :root{--ink:#16181d;--sub:#6b7280;--line:#e5e7eb;--bg:#fff;--card:#fafafa;--accent:#2563eb}
234
+ @media(prefers-color-scheme:dark){:root{--ink:#e8eaed;--sub:#9aa0a6;--line:#2a2d33;--bg:#15171a;--card:#1c1f24;--accent:#7aa2f7}}
235
+ *{box-sizing:border-box}body{margin:0;font:14px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--ink);background:var(--bg)}
236
+ header{padding:20px 24px;border-bottom:1px solid var(--line)}h1{margin:0 0 6px;font-size:17px}
237
+ .meta{color:var(--sub);font-size:12.5px}
238
+ main{max-width:1080px;margin:0 auto;padding:20px 24px 60px}
239
+ section{margin:22px 0}h2{font-size:14px;margin:0 0 10px;color:var(--accent)}
240
+ table{width:100%;border-collapse:collapse;font-size:13px}td{padding:5px 8px;border-bottom:1px solid var(--line);vertical-align:top}
241
+ td.n{font-weight:600;width:26%}td.k{color:var(--sub);width:8%}td.f{color:var(--sub);font-family:ui-monospace,Menlo,monospace;font-size:12px;word-break:break-all}
242
+ ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}
243
+ li{background:var(--card);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:12.5px;display:flex;align-items:center;gap:6px}
244
+ code{font-family:ui-monospace,Menlo,monospace}.v{color:var(--sub)}.u{color:var(--sub);font-size:11px}
245
+ .sw{width:13px;height:13px;border-radius:3px;border:1px solid var(--line);display:inline-block}
246
+ .notes{display:block}.notes li{display:block;background:transparent;border:0;border-left:3px solid var(--accent);border-radius:0;padding:3px 0 3px 10px;color:var(--sub)}
247
+ .empty{padding:26px;border:1px dashed var(--line);border-radius:10px;color:var(--sub);text-align:center}
248
+ .tbl{overflow-x:auto}
249
+ </style></head><body>
250
+ <header>
251
+ <h1>${_esc(meta.project || 'project')} — 재사용 인벤토리</h1>
252
+ <div class="meta">스택 ${_esc((r.stack || []).join(' · ') || '미상')} · 토큰 출처 ${_esc(r.tokenSource)} · 파일 ${r.stats.scanned}개 스캔 · ${_esc(meta.at || '')}</div>
253
+ <div class="meta">이 파일은 <b>생성물</b>입니다 — 손으로 고치지 말고 <code>leerness library page</code> 로 다시 만드세요.</div>
254
+ </header>
255
+ <main>
256
+ ${notes ? `<section><h2>읽기 전에</h2><ul class="notes">\n${notes}\n</ul></section>` : ''}
257
+ ${empty ? '<section><div class="empty">인벤토리가 비어 있습니다. 위 안내가 이유를 설명합니다 — 빈 결과를 채워 넣지 않았습니다.</div></section>' : ''}
258
+ ${r.components.length ? `<section><h2>컴포넌트 ${r.components.length}개 — 새로 만들기 전에 여기부터</h2><div class="tbl"><table>\n${rows}\n</table></div></section>` : ''}
259
+ ${r.tokens.cssVars.length ? `<section><h2>CSS 커스텀 프로퍼티 ${r.tokens.cssVars.length}개</h2><ul>\n${vars}\n</ul></section>` : ''}
260
+ ${r.tokens.utilities.length ? `<section><h2>유틸리티 어휘 ${r.tokens.utilities.length}종 — 빈도순(3회 이상만)</h2><ul>\n${utils}\n</ul></section>` : ''}
261
+ </main></body></html>
262
+ `;
263
+ }
264
+
265
+ // `leerness library [show|page|--json|--ai]`
266
+ // show(기본) 사람이 터미널에서 훑는 요약 · page 오프라인 단일 HTML · --json 전체 · --ai 압축(에이전트 컨텍스트용)
267
+ function libraryCmd(root, sub, deps = {}) {
268
+ const { has, log, ok, warn, failJson } = deps;
269
+ root = absRoot(root);
270
+ const json = !!(has && has('--json'));
271
+ const ai = !!(has && has('--ai'));
272
+ if (!exists(path.join(root, '.harness'))) { failJson(json || ai, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
273
+ const r = scanLibrary(root);
274
+ if (ai) { log(JSON.stringify(compactLibrary(r))); return; }
275
+ if (json) { log(JSON.stringify(Object.assign({ ok: true }, r), null, 2)); return; }
276
+ if (sub === 'page') {
277
+ const at = new Date().toISOString().slice(0, 19).replace('T', ' ');
278
+ const out = path.join(root, 'leerness-library.html');
279
+ require('./io').writeUtf8(out, renderLibraryPage(r, { project: path.basename(root), at }));
280
+ ok(`재사용 인벤토리 페이지 생성: ${path.relative(process.cwd(), out).replace(/\\/g, '/')}`);
281
+ log(` 컴포넌트 ${r.components.length} · CSS 변수 ${r.tokens.cssVars.length} · 유틸 ${r.tokens.utilities.length} · 파일 ${r.stats.scanned}개 스캔`);
282
+ r.notes.forEach(n => log(` ⓘ ${n}`));
283
+ return;
284
+ }
285
+ log(`# leerness library — 재사용 인벤토리 (${path.basename(root)})`);
286
+ log(` 스택: ${(r.stack || []).join(' · ') || '(미상)'} · 토큰 출처: ${r.tokenSource} · 파일 ${r.stats.scanned}개 스캔`);
287
+ log('');
288
+ if (r.components.length) {
289
+ log(` 컴포넌트 ${r.components.length}개 — 새로 만들기 전에 여기부터 (상위 20)`);
290
+ for (const c of r.components.slice(0, 20)) log(` · ${c.name.padEnd(26)} ${c.file}:${c.line}`);
291
+ if (r.components.length > 20) log(` … ${r.components.length - 20}개 더 (leerness library page 로 전체 열람)`);
292
+ } else log(' 컴포넌트: (없음)');
293
+ log('');
294
+ if (r.tokens.cssVars.length) log(` CSS 변수 ${r.tokens.cssVars.length}개: ${r.tokens.cssVars.slice(0, 10).map(v => '--' + v.name).join(' ')}${r.tokens.cssVars.length > 10 ? ' …' : ''}`);
295
+ if (r.tokens.utilities.length) log(` 유틸 어휘 ${r.tokens.utilities.length}종(3회 이상): ${r.tokens.utilities.slice(0, 10).map(u => u.name).join(' ')}${r.tokens.utilities.length > 10 ? ' …' : ''}`);
296
+ if (r.notes.length) { log(''); r.notes.forEach(n => warn(n)); }
297
+ log('');
298
+ log(' 사람용 페이지: leerness library page · 에이전트용 압축: leerness library --ai');
299
+ }
300
+
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 };
@@ -10,6 +10,32 @@ const fs = require('fs');
10
10
  const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
11
11
  const { _sanitizeFences, _parseArchiveBlocks } = require('./pure-utils');
12
12
 
13
+ // 1.36.98 (사용자 보고, hive-analytics): session close 가 current-state.md 를 통째로 갈아치웠다.
14
+ // 종전 구현은 세 개의 정규식으로 섹션 본문을 '한 줄' 로 교체했고, 마지막 하나는 `/## Blockers\n[\s\S]*$/` 라
15
+ // **Blockers 부터 파일 끝까지** 를 지웠다 — 그 아래에 사람이 적어 둔 섹션이 통째로 사라졌다.
16
+ // 실측(재현): 440자 → 88자, 사용자 작성 섹션 2개(결정 로그·인수인계 메모) 전부 소실.
17
+ // session close 는 제품이 "매 세션 끝에 반드시 실행" 하라고 지시하는 명령이라 손실이 매번 일어났다.
18
+ //
19
+ // 고친 방식: 도구가 쓴 줄에 표지를 달고, **표지 있는 줄만** 교체한다. 표지 없는 줄은 절대 지우지 않는다.
20
+ // 섹션 범위도 EOF 가 아니라 '다음 ## 제목' 까지로 닫는다. 표지가 없던 구버전 파일의 마지막 생성 줄 하나는
21
+ // 한 번 남지만(누적되지 않는다), 사용자 문서를 지우는 것보다 그 편이 낫다 — 안전한 쪽으로 실패한다.
22
+ const AUTO_MARK = '<!-- leerness:auto -->';
23
+ function _upsertAutoLines(md, heading, autoLines) {
24
+ const re = new RegExp(`(?:^|\\n)## ${heading}[ \\t]*\\r?\\n`);
25
+ const m = re.exec(md);
26
+ if (!m) return md; // 섹션이 없으면 만들지 않는다(문서 구조를 마음대로 바꾸지 않는다)
27
+ const bodyStart = m.index + m[0].length;
28
+ const nextIdx = md.indexOf('\n## ', bodyStart);
29
+ const bodyEnd = nextIdx < 0 ? md.length : nextIdx + 1;
30
+ const body = md.slice(bodyStart, bodyEnd);
31
+ const kept = body.split('\n').filter(l => !l.includes(AUTO_MARK));
32
+ while (kept.length && !kept[0].trim()) kept.shift(); // 섹션 머리의 빈 줄만 정리
33
+ while (kept.length && !kept[kept.length - 1].trim()) kept.pop();
34
+ const merged = autoLines.map(l => `${l} ${AUTO_MARK}`).concat(kept).join('\n');
35
+ // 다음 제목 앞의 빈 줄은 되돌려 준다(가독성). 매 실행마다 꼬리 빈 줄을 걷어내므로 반복 실행해도 늘지 않는다.
36
+ return md.slice(0, bodyStart) + merged + '\n' + (nextIdx < 0 ? '' : '\n' + md.slice(bodyEnd));
37
+ }
38
+
13
39
  function sessionClose(root, opts = {}, deps = {}) {
14
40
  const { VERSION, STATUSES, MARK, has, arg, uiLang, harnessPath, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings } = deps;
15
41
  // 1.22.1 (UR-0010 Phase 4): 마감 보고 UI 언어 — 영어 opt-in, 한국어 기본(uiLang 미주입 시 ko).
@@ -112,9 +138,9 @@ function sessionClose(root, opts = {}, deps = {}) {
112
138
  if (exists(currentStatePath(root))) {
113
139
  let cs = read(currentStatePath(root));
114
140
  cs = cs.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
115
- cs = cs.replace(/## Now\n[\s\S]*?(?=\n## Next)/, `## Now\n- ${(buckets['in-progress'][0]?.request) || '대기 중'}\n`);
116
- cs = cs.replace(/## Next\n[\s\S]*?(?=\n## Blockers)/, `## Next\n- ${(buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '계획된 작업 없음'}\n`);
117
- cs = cs.replace(/## Blockers\n[\s\S]*$/, `## Blockers\n${(buckets['blocked']||[]).map(b=>`- ${b.id} ${b.request}`).join('\n') || '-'}\n`);
141
+ cs = _upsertAutoLines(cs, 'Now', [`- ${(buckets['in-progress'][0]?.request) || '대기 중'}`]);
142
+ cs = _upsertAutoLines(cs, 'Next', [`- ${(buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '계획된 작업 없음'}`]);
143
+ cs = _upsertAutoLines(cs, 'Blockers', (buckets['blocked'] || []).length ? buckets['blocked'].map(b => `- ${b.id} ${b.request}`) : ['- (없음)']);
118
144
  writeUtf8(currentStatePath(root), cs);
119
145
  }
120
146
 
@@ -657,4 +683,4 @@ function sessionClose(root, opts = {}, deps = {}) {
657
683
  }
658
684
  }
659
685
 
660
- module.exports = { sessionClose };
686
+ module.exports = { sessionClose, _upsertAutoLines }; // 1.36.98: 보존 규칙을 selftest 가 직접 검증할 수 있게 노출
@@ -46,6 +46,28 @@ const SERVICE_SIGNALS = [
46
46
  { id: 'github-api', deps: ['@octokit/rest', 'octokit'], env: ['GH_TOKEN', 'GITHUB_TOKEN'] },
47
47
  ];
48
48
 
49
+ // 1.36.98 (P-0013): UI 스택 — 언어/서비스와 달리 '무엇으로 화면을 그리는가' 는 종전에 전혀 감지하지 않았다.
50
+ // 라이브러리 인벤토리와 시안 생성기가 스택별로 다른 추출 전략을 써야 하므로, 판정은 여기 한 곳에서만 한다
51
+ // (표면마다 따로 판정하면 반드시 어긋난다 — 1.36.97 에서 도메인 목록으로 같은 실수를 했다).
52
+ // 실측 근거(실제 12개 프로젝트): Tailwind 를 쓰는 7개는 CSS 커스텀 프로퍼티가 0~5개뿐이고 유틸 어휘가 74~121종이며,
53
+ // Tailwind 를 안 쓰는 프로젝트는 정반대(CSS 변수 58개, 유틸 0). 토큰을 한쪽에서만 찾으면 절반이 '토큰 없음' 이 된다.
54
+ const UI_SIGNALS = [
55
+ { id: 'next', deps: ['next'], files: ['next.config.js', 'next.config.mjs', 'next.config.ts'] },
56
+ { id: 'nuxt', deps: ['nuxt', 'nuxt3'], files: ['nuxt.config.ts', 'nuxt.config.js'] },
57
+ { id: 'react', deps: ['react', 'react-dom'] },
58
+ { id: 'vue', deps: ['vue'] },
59
+ { id: 'svelte', deps: ['svelte', '@sveltejs/kit'] },
60
+ { id: 'angular', deps: ['@angular/core'] },
61
+ { id: 'solid', deps: ['solid-js'] },
62
+ { id: 'astro', deps: ['astro'], files: ['astro.config.mjs'] },
63
+ { id: 'tailwind', deps: ['tailwindcss'], files: ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs', 'tailwind.config.mjs'] },
64
+ { id: 'styled-components', deps: ['styled-components'] },
65
+ { id: 'emotion', deps: ['@emotion/react', '@emotion/styled'] },
66
+ { id: 'sass', deps: ['sass', 'node-sass'] },
67
+ { id: 'vite', deps: ['vite'], files: ['vite.config.js', 'vite.config.ts'] },
68
+ { id: 'django', files: ['manage.py'] },
69
+ { id: 'flask', deps: [] },
70
+ ];
49
71
  function _envKeys(root) {
50
72
  const out = [];
51
73
  for (const f of ['.env', '.env.local', '.env.production']) {
@@ -71,6 +93,9 @@ function _shallowExts(root) {
71
93
  if (e.isDirectory()) { if (depth > 0) scan(p, depth - 1); continue; }
72
94
  const ext = path.extname(e.name).toLowerCase();
73
95
  if (EXT_LANG[ext]) { counts[EXT_LANG[ext]] = (counts[EXT_LANG[ext]] || 0) + 1; _extBudget--; }
96
+ // 1.36.98 (P-0013): html 은 '언어' 가 아니다 — 이 counts 의 모든 키는 언어로 등재되므로(아래 addL 루프)
97
+ // 충돌하지 않는 '#' 접두 키에 따로 담고, 언어 루프에서 건너뛴다. UI 스택 판정(plain-html)에만 쓴다.
98
+ else if (ext === '.html' || ext === '.htm') { counts['#html'] = (counts['#html'] || 0) + 1; _extBudget--; }
74
99
  }
75
100
  };
76
101
  scan(absRoot(root), 3); // 1.36.79 (도그푸딩 P1-D): depth 2→3 — src/pkg/mod 3단 레이아웃의 소스가 통째로 안 세어지던 것
@@ -87,7 +112,7 @@ function detectTechProfile(root) {
87
112
  if (s.glob) { try { if (fs.readdirSync(root).some(n => s.glob.test(n))) addL(s.id, 'project file'); } catch {} }
88
113
  }
89
114
  const extCounts = _shallowExts(root);
90
- for (const [lang, n] of Object.entries(extCounts)) if (n >= 3) addL(lang, `${n} source files`);
115
+ for (const [lang, n] of Object.entries(extCounts)) if (n >= 3 && lang[0] !== '#') addL(lang, `${n} source files`); // '#' 접두는 언어 아님(#html)
91
116
  // 1.36.79 (도그푸딩 P1-D, 실 프로젝트): 마커 파일 존재만으로 순서가 정해져 **devDependency 1개짜리 package.json 이
92
117
  // 205개 .py 파일을 이겨** Python 모노레포가 "javascript" 로 라벨링됐다(실측). 실제 소스 파일 수를 1순위 근거로
93
118
  // 재정렬 — 파일이 많은 언어가 앞, 동수면 기존(마커) 순서 유지. 마커-only(0 파일)는 뒤로.
@@ -127,7 +152,21 @@ function detectTechProfile(root) {
127
152
  if (!seenS.has(s.id)) for (const pre of (s.env || [])) { const hit = envKeys.find(k => k === pre || k.startsWith(pre)); if (hit) { addS(s.id, `env: ${hit}`); break; } }
128
153
  if (!seenS.has(s.id)) for (const f of (s.files || [])) if (exists(path.join(root, f))) { addS(s.id, `file: ${f}`); break; }
129
154
  }
130
- return { languages, services };
155
+ // UI 스택 deps/설정파일로만 판정한다(파일 내용은 안 읽는다: 비용·오탐 모두 커진다).
156
+ // 플레인 HTML 은 '프레임워크 신호가 하나도 없고 .html 이 있을 때' 만 — 프레임워크 프로젝트에도 index.html 은 흔하다.
157
+ const ui = []; const seenU = new Set();
158
+ const addU = (id, evidence) => { if (!seenU.has(id)) { seenU.add(id); ui.push({ id, evidence }); } };
159
+ for (const u of UI_SIGNALS) {
160
+ for (const d of (u.deps || [])) if (deps[d]) { addU(u.id, `dep: ${d}`); break; }
161
+ if (!seenU.has(u.id)) for (const f of (u.files || [])) if (exists(path.join(root, f))) { addU(u.id, `file: ${f}`); break; }
162
+ }
163
+ if (/^\s*(?:flask|Flask)\s*(?:[=<>!~;[]|$)/mi.test(reqTxt)) addU('flask', 'requirements: flask');
164
+ if (/^\s*django\s*(?:[=<>!~;[]|$)/mi.test(reqTxt)) addU('django', 'requirements: django');
165
+ if (!ui.length) {
166
+ const htmlCount = extCounts['#html'] || 0;
167
+ if (htmlCount > 0) addU('plain-html', `${htmlCount} html files · 프레임워크 신호 없음`);
168
+ }
169
+ return { languages, services, ui };
131
170
  }
132
171
 
133
172
  function _techPath(root) { return path.join(absRoot(root), '.harness', 'tech-profile.json'); }
@@ -154,9 +193,14 @@ function refreshTechProfile(root, opts = {}) {
154
193
  if (prev && prev.current) {
155
194
  const aL = dif(ids(cur.languages), ids(prev.current.languages)), rL = dif(ids(prev.current.languages), ids(cur.languages));
156
195
  const aS = dif(ids(cur.services), ids(prev.current.services)), rS = dif(ids(prev.current.services), ids(cur.services));
157
- if (aL.length || rL.length || aS.length || rS.length) {
196
+ // 1.36.98 (P-0013): ui 변화도 '변경' 으로 친다 — 빼먹으면 기존 프로필을 가진 프로젝트는 언어/서비스가 바뀔 때까지
197
+ // ui 가 영영 기록되지 않는다(구버전 프로필엔 ui 자체가 없으므로 첫 갱신이 곧 전부 added 다).
198
+ const aU = dif(ids(cur.ui || []), ids(prev.current.ui || [])), rU = dif(ids(prev.current.ui || []), ids(cur.ui || []));
199
+ if (aL.length || rL.length || aS.length || rS.length || aU.length || rU.length) {
158
200
  changed = true;
159
201
  diff = { at: now(), addedLanguages: aL, removedLanguages: rL, addedServices: aS, removedServices: rS };
202
+ if (aU.length) diff.addedUi = aU;
203
+ if (rU.length) diff.removedUi = rU;
160
204
  history = [...history, diff].slice(-50);
161
205
  }
162
206
  } else if (!prev) { changed = true; }
@@ -183,6 +227,8 @@ function techCmd(root, sub, deps = {}) {
183
227
  log(`# leerness tech — 프로젝트 기술 프로필 (${p.updatedAt.slice(0, 10)})`);
184
228
  log(` 언어: ${p.current.languages.length ? p.current.languages.map(l => `${l.id} (${l.evidence})`).join(' · ') : '(감지 없음)'}`);
185
229
  log(` 서비스: ${p.current.services.length ? p.current.services.map(s => `${s.id} (${s.evidence})`).join(' · ') : '(감지 없음)'}`);
230
+ // 1.36.98 (P-0013): 구버전 프로필에는 ui 가 없다 — 없으면 줄을 아예 내지 않는다(빈 줄로 "감지 실패" 처럼 보이지 않게).
231
+ if (Array.isArray(p.current.ui)) log(` UI: ${p.current.ui.length ? p.current.ui.map(u => `${u.id} (${u.evidence})`).join(' · ') : '(감지 없음)'}`);
186
232
  if (p.history.length) {
187
233
  log(`\n 변경 이력 (${p.history.length}건 — 마이그레이션/언어 전환 추적):`);
188
234
  for (const h of p.history.slice(-8)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.97",
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
@@ -9455,6 +9455,118 @@ total++;
9455
9455
  if (!ok) failed++;
9456
9456
  }
9457
9457
 
9458
+ // ── 1.36.98: session close 가 current-state.md 의 사용자 작성 내용을 지우지 않는가 (사용자 보고 재현 → 실 CLI 로 고정)
9459
+ // selftest 는 순수 함수만 본다. 손실은 '명령을 실제로 돌렸을 때' 났으므로 여기서 실 CLI 로 건다.
9460
+ {
9461
+ total++;
9462
+ let ok = false; const dbg = {};
9463
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-cs98-'));
9464
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9465
+ try {
9466
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'p', version: '0.1.0' }));
9467
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9468
+ const csp = path.join(d, '.harness', 'current-state.md');
9469
+ const authored = ['# Current State', 'Updated: 2026-08-01', '', '## Now', '- 사람이 쓴 진행 메모', '- 미해결 항목 1건', '',
9470
+ '## Next', '- 사람이 쓴 다음 계획', '', '## Blockers', '- 사람이 쓴 차단 사유', '',
9471
+ '## 결정 로그', '- 2026-07-28: 재시도 상한 5회', '', '## 인수인계 메모', '- docs/x.md 부터 읽을 것', ''].join('\n');
9472
+ fs.writeFileSync(csp, authored);
9473
+ R(['session', 'close', d]);
9474
+ const after1 = fs.readFileSync(csp, 'utf8');
9475
+ R(['session', 'close', d]);
9476
+ const after2 = fs.readFileSync(csp, 'utf8');
9477
+ R(['session', 'close', d]);
9478
+ const after3 = fs.readFileSync(csp, 'utf8');
9479
+ const human = ['사람이 쓴 진행 메모', '미해결 항목 1건', '사람이 쓴 다음 계획', '사람이 쓴 차단 사유', '## 결정 로그', '2026-07-28: 재시도 상한 5회', '## 인수인계 메모', 'docs/x.md 부터 읽을 것'];
9480
+ const preserved = human.every(l => after1.includes(l) && after3.includes(l));
9481
+ const stable = after2 === after3; // 반복 마감에 누적되지 않는다
9482
+ const autoOnce = (after3.match(/leerness:auto/g) || []).length === 3; // 자동줄은 섹션당 하나
9483
+ const noBlankRun = !/\n\n\n/.test(after3);
9484
+ const grew = after3.length >= authored.length - 20; // 80% 파괴가 재발하면 여기서 걸린다
9485
+ Object.assign(dbg, { preserved, stable, autoOnce, noBlankRun, len: after3.length, was: authored.length });
9486
+ ok = preserved && stable && autoOnce && noBlankRun && grew;
9487
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9488
+ console.log(ok ? '✓ B(1.36.98) session close 가 current-state.md 의 사용자 작성 섹션을 보존 — 3회 반복에도 누적 0 (사용자 보고 재현 고정)'
9489
+ : '✗ 1.36.98 current-state 보존 실패 ' + JSON.stringify(dbg));
9490
+ if (!ok) failed++;
9491
+ }
9492
+
9493
+ // ── 1.36.98 (P-0013): 재사용 인벤토리 CLI — 추출 결과가 아니라 '무엇을 싣지 않는가' 를 건다.
9494
+ {
9495
+ total++;
9496
+ let ok = false; const dbg = {};
9497
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lib98-'));
9498
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9499
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9500
+ try {
9501
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'p', version: '0.1.0', dependencies: { react: '18', tailwindcss: '3' } }));
9502
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9503
+ const W = (rel, t) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, t); };
9504
+ W('src/Button.tsx', 'export function Button(){ return <button className="px-3 py-1 rounded-lg">go</button> }\n');
9505
+ W('src/schemas.ts', 'export const UserSchema = z.object({ id: z.string() });\nexport const FetchUser = async (i: string): Promise<Response> => fetch(i);\n');
9506
+ W('src/theme.css', ':root { --brand-500: #2563eb; }\n');
9507
+ W('src/notes.ts', '// audit --fix: 누락 키 자동 추가\nexport const runFix = () => 1;\n');
9508
+ const show = out(R(['library', '--path', d]));
9509
+ const shows = /Button/.test(show) && !/UserSchema/.test(show) && !/FetchUser/.test(show);
9510
+ const aiRaw = (R(['library', '--ai', '--path', d]).stdout || '').trim();
9511
+ let aiJson = null; try { aiJson = JSON.parse(aiRaw); } catch {}
9512
+ const aiOk = !!aiJson && Array.isArray(aiJson.components) && aiJson.components.some(c => c.n === 'Button')
9513
+ && !aiJson.components.some(c => c.n === 'UserSchema')
9514
+ && (aiJson.tokens.cssVars || []).includes('brand-500')
9515
+ && !(aiJson.tokens.cssVars || []).includes('fix') // 주석 속 CLI 플래그는 토큰이 아니다
9516
+ && aiJson.stack.includes('tailwind');
9517
+ const pg = R(['library', 'page', '--path', d]);
9518
+ const html = fs.existsSync(path.join(d, 'leerness-library.html')) ? fs.readFileSync(path.join(d, 'leerness-library.html'), 'utf8') : '';
9519
+ const pageOk = pg.status === 0 && /Button/.test(html) && !/UserSchema/.test(html)
9520
+ && !/(?:src|href)\s*=\s*["'](?!#)/i.test(html) && !/<script/i.test(html); // 오프라인 단일 파일(외부 리소스·스크립트 0)
9521
+ // 사람용 페이지가 에이전트 페이로드보다 커야 분리의 의미가 있다
9522
+ const splitOk = html.length > aiRaw.length;
9523
+ Object.assign(dbg, { shows, aiOk, pageOk, splitOk, html: html.length, ai: aiRaw.length });
9524
+ ok = shows && aiOk && pageOk && splitOk;
9525
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9526
+ console.log(ok ? '✓ B(1.36.98/P-0013) 재사용 인벤토리: 컴포넌트 추출 + zod/제네릭 오탐 0 + 주석 CLI 플래그 토큰화 0 + 오프라인 페이지(외부참조 0) + AI 압축 분리'
9527
+ : '✗ 1.36.98 재사용 인벤토리 실패 ' + JSON.stringify(dbg));
9528
+ if (!ok) failed++;
9529
+ }
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
+
9458
9570
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
9459
9571
  if (failed > 0) process.exit(1);
9460
9572