leerness 1.36.96 → 1.36.98

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/lib/library.js ADDED
@@ -0,0 +1,297 @@
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
+ const FILE_CAP = 1024 * 1024; // 파일당
25
+ const TOTAL_CAP = 48 * 1024 * 1024; // 스캔 1회당 — 비용은 파일 하나가 아니라 호출 하나에 묶는다(1.36.97 교훈)
26
+ const MAX_FILES = 6000;
27
+
28
+ // 선언 근처에 마크업이 있는가 — '증명 가능한 하한' 의 판정자.
29
+ // JSX(`return <` / `=> <` / `<Tag ... />`)나 createElement 호출이 창(window) 안에 있어야 컴포넌트로 본다.
30
+ const MARKUP_RE = /return\s*\(?\s*</;
31
+ const MARKUP_ARROW_RE = /=>\s*\(?\s*</;
32
+ const CREATE_EL_RE = /(?:React\.)?createElement\s*\(/;
33
+ // TypeScript 제네릭(Record<string>, useState<number>)은 JSX 태그와 글자가 같다 — 실측에서 zod 스키마가
34
+ // 컴포넌트로 잡혔다(EmojiDensitySchema 등). 차이는 위치다: 제네릭은 '식별자 바로 뒤', JSX 는 '표현식 자리'.
35
+ // 그래서 `<` 앞에 단어 문자가 오면 제네릭으로 보고 버린다.
36
+ // 그리고 문자열·주석의 '자리표시자' 도 태그처럼 생겼다 — 실측에서 `:<svc>` `<uuid>` `<browserPort>` 때문에
37
+ // UI 를 그리지 않는 감시 클래스 4개가 컴포넌트로 실렸다(view-work 50개 중 4개). 진짜 마크업은 둘 중 하나다:
38
+ // 대문자로 시작하는 컴포넌트 태그이거나, 속성을 가진 소문자 요소. 속성 없는 소문자 한 낱말은 자리표시자로 본다.
39
+ const JSX_TAG_RE = /(?:^|[\s(={,;:?[])<(?:[A-Z][\w.]*(?:\s[^<>]{0,200})?\/?>|[a-z][\w-]*\s+[a-zA-Z-]+\s*=[^<>]{0,200}>)/m;
40
+ const DECL_RE = /export\s+(?:default\s+)?(?:function|const|let|class)\s+([A-Za-z_$][\w$]*)/g;
41
+ // 창의 '끝' 은 다음 선언이다 — 고정 문자 수로 자르면 둘 다 틀린다. 실측: 선언→첫 마크업 거리는 p50 684 · p90 3,248 ·
42
+ // p99 20,639 자라, 1,500 자 창은 실제 선언의 69%만 포착했고(누락 30%), 창을 6,000 으로 키우면 사실상 '파일에 JSX 가
43
+ // 있으면 전부' 가 되어 2~3배 부풀리던 옛 휴리스틱으로 되돌아간다. 경계를 구조에서 가져오면 상수가 필요 없다.
44
+ // 경계는 '최상위' 선언만이다 — 들여쓰기를 허용하면 컴포넌트 본문 첫 줄의 `const [s, setS] = useState()` 가
45
+ // 곧바로 창을 닫아 버려(실측: 컴포넌트 수가 7·6·0 으로 붕괴) 반대쪽으로 틀린다. 열 0 이 곧 최상위라는 신호다.
46
+ const BOUNDARY_RE = /\n(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+[A-Za-z_$][\w$]*/g;
47
+ const ALL_CAPS_RE = /^[A-Z0-9_]+$/;
48
+
49
+ function _hasMarkup(win) {
50
+ return MARKUP_RE.test(win) || MARKUP_ARROW_RE.test(win) || CREATE_EL_RE.test(win) || JSX_TAG_RE.test(win);
51
+ }
52
+ // 하위호환 + 단위 검증용: 시작점부터 '다음 선언 전까지' 를 본다(끝을 못 찾으면 파일 끝까지).
53
+ function _hasMarkupNear(text, from) {
54
+ BOUNDARY_RE.lastIndex = from + 1;
55
+ const m = BOUNDARY_RE.exec(text);
56
+ return _hasMarkup(text.slice(from, m ? m.index : text.length));
57
+ }
58
+
59
+ // 파일 순회 — root 밖으로 나가지 않고(심볼릭 디렉토리 미추적), 예산 안에서만 읽는다.
60
+ function _walk(root, opts) {
61
+ const cap = (opts && opts.totalCap) || TOTAL_CAP;
62
+ const fileCap = (opts && opts.fileCap) || FILE_CAP;
63
+ const maxFiles = (opts && opts.maxFiles) || MAX_FILES;
64
+ const out = []; const stats = { scanned: 0, skippedLarge: 0, skippedGenerated: 0, budgetExhausted: false };
65
+ let budget = cap;
66
+ const rec = (dir, depth) => {
67
+ if (depth > 10 || out.length >= maxFiles) return;
68
+ let ents = [];
69
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
70
+ for (const e of ents) {
71
+ if (out.length >= maxFiles) return;
72
+ if (e.name.startsWith('.') && e.name !== '.') { if (SKIP_DIR_RE.test(e.name)) continue; }
73
+ const p = path.join(dir, e.name);
74
+ if (e.isSymbolicLink()) continue; // 링크는 따라가지 않는다(밖으로 새는 유일한 경로)
75
+ if (e.isDirectory()) { if (!SKIP_DIR_RE.test(e.name)) rec(p, depth + 1); continue; }
76
+ if (!e.isFile() || !UI_EXT_RE.test(e.name)) continue;
77
+ if (TEST_RE.test(p) || MINIFIED_RE.test(e.name)) { stats.skippedGenerated++; continue; }
78
+ let st; try { st = fs.statSync(p); } catch { continue; }
79
+ if (st.size > fileCap) { stats.skippedLarge++; continue; }
80
+ if (st.size > budget) { stats.budgetExhausted = true; continue; }
81
+ let txt; try { txt = fs.readFileSync(p, 'utf8'); } catch { continue; }
82
+ // 기계 생성(한 줄이 지나치게 긴 파일)은 어휘 통계를 오염시킨다
83
+ const lines = txt.split('\n').length;
84
+ if (lines > 0 && txt.length / lines > 400) { stats.skippedGenerated++; continue; }
85
+ budget -= st.size;
86
+ stats.scanned++;
87
+ out.push({ rel: path.relative(root, p).replace(/\\/g, '/'), text: txt });
88
+ }
89
+ };
90
+ rec(root, 0);
91
+ return { files: out, stats };
92
+ }
93
+
94
+ // 컴포넌트 — 스택별로 판정이 다르다. .vue/.svelte 는 파일 자체가 컴포넌트이고, JSX 계열은 선언 근처 마크업을 요구한다.
95
+ function _components(files) {
96
+ const out = []; const seen = new Set();
97
+ const add = (name, rel, line, kind) => { const k = name + '@' + rel; if (!seen.has(k)) { seen.add(k); out.push({ name, file: rel, line, kind }); } };
98
+ for (const f of files) {
99
+ const ext = (f.rel.match(/\.([a-z]+)$/i) || [, ''])[1].toLowerCase();
100
+ if (ext === 'vue' || ext === 'svelte' || ext === 'astro') {
101
+ const base = f.rel.split('/').pop().replace(/\.[a-z]+$/i, '');
102
+ if (/^[A-Z]/.test(base)) add(base, f.rel, 1, ext); // 관례상 대문자 파일명이 컴포넌트(소문자는 페이지·라우트)
103
+ continue;
104
+ }
105
+ if (!/^(?:jsx|tsx|js|mjs|cjs|ts)$/.test(ext)) continue;
106
+ let m; DECL_RE.lastIndex = 0;
107
+ while ((m = DECL_RE.exec(f.text))) {
108
+ const name = m[1];
109
+ if (!/^[A-Z]/.test(name) || ALL_CAPS_RE.test(name)) continue; // 소문자 유틸·대문자 상수(API_URL)는 컴포넌트가 아니다
110
+ if (!_hasMarkupNear(f.text, m.index)) continue; // ← 하한의 핵심: 마크업이 없으면 세지 않는다
111
+ const line = f.text.slice(0, m.index).split('\n').length;
112
+ add(name, f.rel, line, ext);
113
+ }
114
+ }
115
+ return out.sort((a, b) => a.name.localeCompare(b.name));
116
+ }
117
+
118
+ // 디자인 토큰 — 두 출처를 모두 본다. 하나만 보면 스택 절반에서 '토큰 없음' 이 된다.
119
+ const CSSVAR_RE = /--([a-zA-Z][\w-]*)\s*:\s*([^;}\n]{1,80})/g;
120
+ const CLASS_ATTR_RE = /class(?:Name)?\s*=\s*(?:["'`]([^"'`]{1,600})["'`]|\{\s*["'`]([^"'`]{1,600})["'`]\s*\})/g;
121
+ // 유틸 어휘: 색/간격/타이포처럼 '디자인 결정' 을 담은 것만. 레이아웃 플래그(flex, block)는 토큰이 아니라 구조다.
122
+ 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;
123
+ // CSS 변수는 'CSS 문맥' 에서만 뽑는다. 파일 전체에서 `--이름:` 을 찾으면 주석·문자열의 CLI 플래그가 그대로 들어온다
124
+ // (실측: 이 저장소에서 --json · --apply · --fix · --strict 가 '디자인 토큰' 으로 실렸다 — `audit --fix: 누락 키 자동 추가`).
125
+ // <style> 추출은 정규식 대신 인덱스 탐색으로 한다: 닫는 태그가 없는 입력에서 되돌림이 생기지 않는다(1.36.97 교훈).
126
+ function _cssSources(rel, text) {
127
+ if (/\.(?:css|scss|less)$/i.test(rel)) return [text];
128
+ const out = []; let i = 0;
129
+ for (;;) {
130
+ const s = text.indexOf('<style', i); if (s < 0) break;
131
+ const gt = text.indexOf('>', s); if (gt < 0) break;
132
+ const e = text.indexOf('</style', gt); if (e < 0) break;
133
+ out.push(text.slice(gt + 1, e)); i = e + 7;
134
+ }
135
+ return out;
136
+ }
137
+ function _tokens(files) {
138
+ const cssVars = new Map(); const utils = new Map();
139
+ for (const f of files) {
140
+ for (const css of _cssSources(f.rel, f.text)) {
141
+ let m; CSSVAR_RE.lastIndex = 0;
142
+ while ((m = CSSVAR_RE.exec(css))) {
143
+ const name = m[1], val = m[2].trim();
144
+ const cur = cssVars.get(name);
145
+ if (cur) cur.uses++; else cssVars.set(name, { name, value: val, file: f.rel, uses: 1 });
146
+ }
147
+ }
148
+ let c; CLASS_ATTR_RE.lastIndex = 0;
149
+ while ((c = CLASS_ATTR_RE.exec(f.text))) {
150
+ const cls = c[1] || c[2] || '';
151
+ let u; UTIL_RE.lastIndex = 0;
152
+ while ((u = UTIL_RE.exec(cls))) utils.set(u[0], (utils.get(u[0]) || 0) + 1);
153
+ }
154
+ }
155
+ // 빈도는 '사실상의 토큰' 이라는 증거다 — 1~2회짜리는 우연이므로 싣지 않는다.
156
+ const utilList = [...utils.entries()].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]).map(([name, uses]) => ({ name, uses }));
157
+ return { cssVars: [...cssVars.values()].sort((a, b) => b.uses - a.uses), utilities: utilList };
158
+ }
159
+
160
+ // 스택별 어떤 출처가 '주' 인지 — 인벤토리를 읽는 쪽이 무엇을 믿어야 하는지 알아야 한다.
161
+ function _tokenSource(ui, tokens) {
162
+ const hasTw = ui.includes('tailwind');
163
+ if (hasTw && tokens.utilities.length) return 'tailwind-utilities';
164
+ if (tokens.cssVars.length) return 'css-variables';
165
+ if (tokens.utilities.length) return 'utility-classes';
166
+ return 'none';
167
+ }
168
+
169
+ function scanLibrary(root, opts = {}) {
170
+ root = absRoot(root);
171
+ let ui = [];
172
+ try { ui = (require('./tech-profile').detectTechProfile(root).ui || []).map(u => u.id); } catch { ui = []; }
173
+ const { files, stats } = _walk(root, opts);
174
+ const components = _components(files);
175
+ const tokens = _tokens(files);
176
+ const notes = [];
177
+ if (!stats.scanned) notes.push('스캔된 UI 파일이 없다 — 이 저장소에 화면 코드가 없거나, 전부 제외 규칙(벤더/빌드/테스트/기계생성)에 걸렸다.');
178
+ else if (!components.length) notes.push(`컴포넌트를 찾지 못했다(${stats.scanned}개 파일 스캔). 선언 근처에 마크업이 있는 것만 세므로, 플레인 HTML 프로젝트나 마크업이 템플릿 파일에만 있는 구조에서는 0 이 정상이다.`);
179
+ if (_tokenSource(ui, tokens) === 'none' && stats.scanned) notes.push('디자인 토큰을 찾지 못했다 — CSS 커스텀 프로퍼티도, 3회 이상 쓰인 유틸 어휘도 없다.');
180
+ if (stats.budgetExhausted) notes.push('스캔 예산을 넘겨 일부 파일을 건너뛰었다 — 인벤토리가 완전하지 않다.');
181
+ if (stats.skippedLarge) notes.push(`${stats.skippedLarge}개 파일이 크기 상한을 넘어 제외됐다.`);
182
+ return {
183
+ stack: ui,
184
+ tokenSource: _tokenSource(ui, tokens),
185
+ components,
186
+ tokens,
187
+ stats,
188
+ notes,
189
+ };
190
+ }
191
+
192
+ // ── AI 참조용 압축 뷰. 사람용 HTML 과 분리하는 게 핵심이다 —
193
+ // 인벤토리 전체를 컨텍스트에 밀어 넣으면 '토큰을 아끼려고 만든 기능' 이 토큰을 더 쓴다.
194
+ // 자르는 경우 반드시 자름을 알린다(조용한 절단은 '전부 봤다' 는 거짓 인상을 준다).
195
+ const AI_CAPS = { components: 200, utilities: 60, cssVars: 60 };
196
+ function compactLibrary(r, caps = AI_CAPS) {
197
+ const cut = {};
198
+ const take = (arr, n, key) => { if (arr.length > n) cut[key] = arr.length - n; return arr.slice(0, n); };
199
+ return {
200
+ stack: r.stack,
201
+ tokenSource: r.tokenSource,
202
+ components: take(r.components, caps.components, 'components').map(c => ({ n: c.name, f: `${c.file}:${c.line}` })),
203
+ tokens: {
204
+ utilities: take(r.tokens.utilities, caps.utilities, 'utilities').map(u => `${u.name}×${u.uses}`),
205
+ cssVars: take(r.tokens.cssVars, caps.cssVars, 'cssVars').map(v => v.name),
206
+ },
207
+ counts: { components: r.components.length, utilities: r.tokens.utilities.length, cssVars: r.tokens.cssVars.length, filesScanned: r.stats.scanned },
208
+ truncated: Object.keys(cut).length ? cut : undefined,
209
+ notes: r.notes,
210
+ };
211
+ }
212
+
213
+ const _esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
214
+ // 색으로 보이는 값만 견본을 그린다 — 값 문자열을 그대로 style 에 넣지 않고, 안전한 형식일 때만 통과시킨다.
215
+ const _COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/;
216
+ function renderLibraryPage(r, meta = {}) {
217
+ 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');
218
+ const vars = r.tokens.cssVars.map(v => {
219
+ const sw = _COLOR_RE.test(v.value.trim()) ? `<i class="sw" style="background:${_esc(v.value.trim())}"></i>` : '';
220
+ return `<li>${sw}<code>--${_esc(v.name)}</code> <span class="v">${_esc(v.value)}</span> <span class="u">${v.uses}회</span></li>`;
221
+ }).join('\n');
222
+ const utils = r.tokens.utilities.map(u => `<li><code>${_esc(u.name)}</code> <span class="u">${u.uses}</span></li>`).join('\n');
223
+ const notes = r.notes.map(n => `<li>${_esc(n)}</li>`).join('\n');
224
+ const empty = !r.components.length && !r.tokens.cssVars.length && !r.tokens.utilities.length;
225
+ return `<!doctype html>
226
+ <html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
227
+ <title>${_esc(meta.project || 'project')} — 재사용 인벤토리</title>
228
+ <style>
229
+ :root{--ink:#16181d;--sub:#6b7280;--line:#e5e7eb;--bg:#fff;--card:#fafafa;--accent:#2563eb}
230
+ @media(prefers-color-scheme:dark){:root{--ink:#e8eaed;--sub:#9aa0a6;--line:#2a2d33;--bg:#15171a;--card:#1c1f24;--accent:#7aa2f7}}
231
+ *{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)}
232
+ header{padding:20px 24px;border-bottom:1px solid var(--line)}h1{margin:0 0 6px;font-size:17px}
233
+ .meta{color:var(--sub);font-size:12.5px}
234
+ main{max-width:1080px;margin:0 auto;padding:20px 24px 60px}
235
+ section{margin:22px 0}h2{font-size:14px;margin:0 0 10px;color:var(--accent)}
236
+ table{width:100%;border-collapse:collapse;font-size:13px}td{padding:5px 8px;border-bottom:1px solid var(--line);vertical-align:top}
237
+ 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}
238
+ ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}
239
+ 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}
240
+ code{font-family:ui-monospace,Menlo,monospace}.v{color:var(--sub)}.u{color:var(--sub);font-size:11px}
241
+ .sw{width:13px;height:13px;border-radius:3px;border:1px solid var(--line);display:inline-block}
242
+ .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)}
243
+ .empty{padding:26px;border:1px dashed var(--line);border-radius:10px;color:var(--sub);text-align:center}
244
+ .tbl{overflow-x:auto}
245
+ </style></head><body>
246
+ <header>
247
+ <h1>${_esc(meta.project || 'project')} — 재사용 인벤토리</h1>
248
+ <div class="meta">스택 ${_esc((r.stack || []).join(' · ') || '미상')} · 토큰 출처 ${_esc(r.tokenSource)} · 파일 ${r.stats.scanned}개 스캔 · ${_esc(meta.at || '')}</div>
249
+ <div class="meta">이 파일은 <b>생성물</b>입니다 — 손으로 고치지 말고 <code>leerness library page</code> 로 다시 만드세요.</div>
250
+ </header>
251
+ <main>
252
+ ${notes ? `<section><h2>읽기 전에</h2><ul class="notes">\n${notes}\n</ul></section>` : ''}
253
+ ${empty ? '<section><div class="empty">인벤토리가 비어 있습니다. 위 안내가 이유를 설명합니다 — 빈 결과를 채워 넣지 않았습니다.</div></section>' : ''}
254
+ ${r.components.length ? `<section><h2>컴포넌트 ${r.components.length}개 — 새로 만들기 전에 여기부터</h2><div class="tbl"><table>\n${rows}\n</table></div></section>` : ''}
255
+ ${r.tokens.cssVars.length ? `<section><h2>CSS 커스텀 프로퍼티 ${r.tokens.cssVars.length}개</h2><ul>\n${vars}\n</ul></section>` : ''}
256
+ ${r.tokens.utilities.length ? `<section><h2>유틸리티 어휘 ${r.tokens.utilities.length}종 — 빈도순(3회 이상만)</h2><ul>\n${utils}\n</ul></section>` : ''}
257
+ </main></body></html>
258
+ `;
259
+ }
260
+
261
+ // `leerness library [show|page|--json|--ai]`
262
+ // show(기본) 사람이 터미널에서 훑는 요약 · page 오프라인 단일 HTML · --json 전체 · --ai 압축(에이전트 컨텍스트용)
263
+ function libraryCmd(root, sub, deps = {}) {
264
+ const { has, log, ok, warn, failJson } = deps;
265
+ root = absRoot(root);
266
+ const json = !!(has && has('--json'));
267
+ const ai = !!(has && has('--ai'));
268
+ if (!exists(path.join(root, '.harness'))) { failJson(json || ai, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
269
+ const r = scanLibrary(root);
270
+ if (ai) { log(JSON.stringify(compactLibrary(r))); return; }
271
+ if (json) { log(JSON.stringify(Object.assign({ ok: true }, r), null, 2)); return; }
272
+ if (sub === 'page') {
273
+ const at = new Date().toISOString().slice(0, 19).replace('T', ' ');
274
+ const out = path.join(root, 'leerness-library.html');
275
+ require('./io').writeUtf8(out, renderLibraryPage(r, { project: path.basename(root), at }));
276
+ ok(`재사용 인벤토리 페이지 생성: ${path.relative(process.cwd(), out).replace(/\\/g, '/')}`);
277
+ log(` 컴포넌트 ${r.components.length} · CSS 변수 ${r.tokens.cssVars.length} · 유틸 ${r.tokens.utilities.length} · 파일 ${r.stats.scanned}개 스캔`);
278
+ r.notes.forEach(n => log(` ⓘ ${n}`));
279
+ return;
280
+ }
281
+ log(`# leerness library — 재사용 인벤토리 (${path.basename(root)})`);
282
+ log(` 스택: ${(r.stack || []).join(' · ') || '(미상)'} · 토큰 출처: ${r.tokenSource} · 파일 ${r.stats.scanned}개 스캔`);
283
+ log('');
284
+ if (r.components.length) {
285
+ log(` 컴포넌트 ${r.components.length}개 — 새로 만들기 전에 여기부터 (상위 20)`);
286
+ for (const c of r.components.slice(0, 20)) log(` · ${c.name.padEnd(26)} ${c.file}:${c.line}`);
287
+ if (r.components.length > 20) log(` … ${r.components.length - 20}개 더 (leerness library page 로 전체 열람)`);
288
+ } else log(' 컴포넌트: (없음)');
289
+ log('');
290
+ 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 ? ' …' : ''}`);
291
+ 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 ? ' …' : ''}`);
292
+ if (r.notes.length) { log(''); r.notes.forEach(n => warn(n)); }
293
+ log('');
294
+ log(' 사람용 페이지: leerness library page · 에이전트용 압축: leerness library --ai');
295
+ }
296
+
297
+ module.exports = { scanLibrary, compactLibrary, renderLibraryPage, libraryCmd, _walk, _components, _tokens, _cssSources, _hasMarkupNear, _tokenSource, _esc, SKIP_DIR_RE, UI_EXT_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.96",
3
+ "version": "1.36.98",
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
@@ -9401,6 +9401,133 @@ total++;
9401
9401
  if (!ok) failed++;
9402
9402
  }
9403
9403
 
9404
+ // ── 1.36.97 (P-0011): 렌즈 3축 심화 — CLI 표면 행위. selftest 는 함수만 보므로 실제 렌더/로케일/완료검증 인라인은 여기서 잰다.
9405
+ {
9406
+ total++;
9407
+ let ok = false; const dbg = {};
9408
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lens97-'));
9409
+ const R = (a, o = {}) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: o.cwd || d, encoding: 'utf8', timeout: 60000 });
9410
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9411
+ const HAN = /[가-힣]/;
9412
+ try {
9413
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9414
+ // ① 심화 렌즈가 실제로 렌더되고 12문항이 다 나오는가(카탈로그에만 있고 표면에 안 나오면 없는 것과 같다)
9415
+ const c = out(R(['lens', 'contract', '--path', d]));
9416
+ const contractOk = /## contract/.test(c) && /12\. /.test(c) && !/13\. /.test(c) && /breaking/.test(c);
9417
+ const rc = out(R(['lens', 'recovery', '--path', d]));
9418
+ const recOk = /## recovery/.test(rc) && /fail-open/.test(rc) && /12\. /.test(rc);
9419
+ const ob = out(R(['lens', 'observability', '--path', d]));
9420
+ const obsOk = /## observability/.test(ob) && /민감정보/.test(ob) && /12\. /.test(ob);
9421
+ const ax = out(R(['lens', 'axes', '--path', d]));
9422
+ const axesOk = /## axes/.test(ax) && /8\. /.test(ax) && !/9\. /.test(ax) && /leerness lens database/.test(ax);
9423
+ // ② 알 수 없는 도메인 안내가 신규 축까지 광고하는가(파생 목록이 표면에 실제로 도달했는지 — 하드코딩이면 여기서 빠진다)
9424
+ const un = R(['lens', 'nosuchaxis', '--path', d]);
9425
+ const unknownOk = un.status === 1 && /observability/.test(out(un)) && /contract/.test(out(un)) && /axes/.test(out(un));
9426
+ // ②-b 도메인 목록을 손으로 적던 세 표면(help ko · help en · commands)이 카탈로그에서 파생되는가.
9427
+ // 위 unknownOk 는 lensCmd 자체 목록이라 이 셋의 낡음을 못 본다 — 절제 시험에서 실제로 통과해 버렸다.
9428
+ const surfaces = [out(R(['commands', '--path', d])), out(R(['--help'])), out(R(['--help', '--language', 'en']))];
9429
+ const surfaceOk = surfaces.every(s => /lens \[/.test(s) && /observability/.test(s) && /\|axes\]/.test(s));
9430
+ // ③ en 로케일 누수 0 — 한글로만 확인하면 영문 경로 구멍을 통째로 놓친다(1.36.89 재발 방지)
9431
+ const en = out(R(['lens', 'recovery', '--language', 'en', '--path', d]));
9432
+ const enOk = /failure & recovery/.test(en) && /fail-open/.test(en) && !HAN.test(en);
9433
+ // ④ 완료 검증 인라인: 경로에 단서 없는 파일이라도 '내용'으로 소환되는가 + 대조군은 조용한가
9434
+ fs.writeFileSync(path.join(d, 'worker.mjs'), 'export async function run(){ try { await go() } catch (e) {} }\n');
9435
+ fs.writeFileSync(path.join(d, 'pure.mjs'), 'export const add = (a, b) => a + b\n');
9436
+ fs.writeFileSync(path.join(d, 'loop.mjs'), 'export async function all(ids){ for (const id of ids) { await fetchOne(id) } }\n');
9437
+ const mk = (title, file) => {
9438
+ const j = R(['task', 'add', title, '--json']);
9439
+ let id = ''; try { id = (JSON.parse(j.stdout || '{}').task || {}).id || ''; } catch {}
9440
+ if (!id) { const m = /(T-\d+)/.exec(out(j)); id = m ? m[1] : ''; }
9441
+ R(['task', 'update', id, '--status', 'done', '--evidence', `구현 ${file} 수정`]);
9442
+ return out(R(['verify-claim', id, '--path', d]));
9443
+ };
9444
+ const vRec = mk('워커 정리', 'worker.mjs');
9445
+ const vPure = mk('순수 헬퍼', 'pure.mjs');
9446
+ const vLoop = mk('일괄 조회', 'loop.mjs');
9447
+ const recallOk = /· recovery\(/.test(vRec) && !/· recovery\(/.test(vPure) && /· code\(/.test(vPure);
9448
+ // ⑤ 경량 축은 신호가 있을 때만 한 줄 — 모든 변경에 붙으면 곧 읽히지 않는 배경음이 된다
9449
+ const axisOk = /· axes\(/.test(vLoop) && /성능·확장성/.test(vLoop) && !/· axes\(/.test(vPure);
9450
+ Object.assign(dbg, { contractOk, recOk, obsOk, axesOk, unknownOk, surfaceOk, enOk, recallOk, axisOk });
9451
+ ok = contractOk && recOk && obsOk && axesOk && unknownOk && surfaceOk && enOk && recallOk && axisOk;
9452
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9453
+ console.log(ok ? '✓ B(1.36.97/P-0011) 렌즈 3축 심화: contract/recovery/observability 12문항 렌더 + axes 8축 + 파생 도메인 목록 + en 누수0 + 내용기반 소환(대조군 침묵) + 축 힌트 조건부'
9454
+ : '✗ 1.36.97 렌즈 3축 심화 실패 ' + JSON.stringify(dbg));
9455
+ if (!ok) failed++;
9456
+ }
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
+
9404
9531
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
9405
9532
  if (failed > 0) process.exit(1);
9406
9533