leerness 1.36.98 → 1.36.100

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.100 — 2026-08-05 — 승인된 시안을 계약으로 고정하고, 구현이 지켰는지 기계로 대조한다 (P-0012 후반)
4
+
5
+ 사용자가 고른 방식의 후반부다: *"승인 시 그대로 적용"* 을 **"시안을 계약으로 고정 + 구현 후 자동 대조"** 로 구현한다.
6
+
7
+ 프레임워크 프로젝트에서 렌더된 마크업을 컴포넌트 소스로 되돌릴 수는 없다. 그래서 **"자동 적용" 이라고 부르지 않는다.** 대신 시안이 약속한 것을 저장해 두고, 구현이 끝난 뒤 그 약속이 지켜졌는지 센다.
8
+
9
+ - 시안에 `<!-- leerness:draw-start -->` / `draw-end` 마커를 넣었다. AI 는 그 **사이만** 그리고, 승인 시 그 구간의 클래스·`var(--토큰)`·요소가 계약으로 고정된다(스캐폴드 자신의 장식은 계약에서 제외).
10
+ - `leerness preview verify <P-ID> --files a,b` — 구현 파일에서 계약된 클래스·토큰이 나타나는지 대조하고 **누락 목록과 존재율**을 낸다. 실측: 계약을 무시한 구현 8% · 계약대로 구현 100%.
11
+ - 아직 안 그린 시안을 승인하면 *"계약이 비어 대조가 무의미합니다"* 라고 말한다 — 빈 계약을 조용히 만들지 않는다.
12
+
13
+ **한계를 매번 말한다.** 이건 **존재 검사**지 시각적 일치 검사가 아니다. 클래스가 있다고 같아 보이는 건 아니다 — 그건 사람이 시안과 화면을 나란히 봐야 한다. 이 구분을 흐리면 1.36.5 에서 고쳤던 과장(휴리스틱을 측정된 판정처럼 광고)을 그대로 되풀이하게 된다. 그래서 출력·JSON·도움말 모두에 `presence-only` 를 박았고, e2e 가 그 문구의 존재까지 단언한다.
14
+
15
+ 구현 중 판정 하나가 틀렸다. "아직 안 그렸다" 를 **글자 수**로 봤는데 도구가 넣어 둔 안내 문구만으로도 120자를 넘어 경고가 안 떴다 — 손대지 않았다는 확실한 신호는 길이가 아니라 **그 자리에 placeholder 가 그대로 있다는 것**이었다.
16
+
17
+ ## 1.36.99 — 2026-08-05 — 시안을 빈 종이가 아니라 이 앱의 재료로 시작시킨다 (P-0012)
18
+
19
+ 사용자 지적: *"mockup.html 로 미리보기 시안의 정확도가 높지 않은데 — 개발하는 프로젝트 언어 등을 참고해서, 수정하려는 페이지나 요소를 복제해 높은 정확도의 시안을 보여줄 수 있나?"*
20
+
21
+ 원인은 코드에 그대로 있었다. 종전 스캐폴드는 점선 박스 하나에 *"AI 는 이 영역을 실제 레이아웃으로 교체하세요"* 라고 적어 둔 게 전부였고, 프로젝트의 스택도 기존 컴포넌트도 디자인 토큰도 읽지 않았다. **AI 의 상상에 100% 의존하니 정확할 수가 없었다.**
22
+
23
+ 시안을 **이 앱의 재료**로 시작시킨다.
24
+ - **실제 CSS 변수를 `:root` 에 심는다** — 시안이 앱과 같은 색·간격으로 그려진다. `var(--brand-500)` 이 진짜 값을 갖는다.
25
+ - **이미 있는 컴포넌트를 어휘로 준다** — "같은 걸 또 만들지 마세요" 와 함께 파일 경로까지. 새로 만들기 전에 조립하게 한다.
26
+ - **`--target <파일>` 로 대상 화면을 지정하면** 그 화면이 실제로 쓰는 클래스와 요소를 싣는다. 소스에서 읽은 것이지 렌더 결과가 아니라는 한계도 시안에 함께 적는다(파서 없이 0-의존성으로 할 수 있는 정직한 상한이다). 루트 밖 경로는 읽지 않는다.
27
+ - 자주 쓰는 유틸 어휘를 빈도순으로 준다 — 새 간격·새 색을 발명하지 말라는 근거가 된다.
28
+
29
+ **브라우저로 열어 보고 진짜 결함을 하나 찾았다.** 토큰 목록에 `--panel`·`--mut`·`--txt` 가 있었는데, 이건 사용자 앱의 토큰이 아니라 **leerness 자신이 그 저장소에 만들어 둔 대시보드(`leerness.html`)의 CSS 변수**였다. 시안이 존재하지도 않는 토큰을 쓰라고 지시할 뻔했다. 자기 산출물(`leerness.html`·`roadmap.html`·`leerness-library.html`·시안 파일)을 인벤토리에서 제외한다.
30
+
31
+ 실 프로젝트 영향이 작지 않았다 — 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 이다 — 여기서 정정한다.
32
+
33
+ 가드: selftest 343 · 변이 3/3(자기산출물 제외) · e2e 절제 6/6. 절제 1차에서 자기산출물 케이스가 살아남았는데 **픽스처에 `leerness.html` 이 없어 단언이 공허하게 통과**한 것이었다(e2e 는 대시보드 자동생성을 끄고 돈다) — 픽스처에 명시적으로 넣어 되살렸다.
34
+
3
35
  ## 1.36.98 — 2026-08-04 — session close 가 사용자 문서를 지우고 있었다 + 재사용 인벤토리 (P-0013)
4
36
 
5
37
  ### 🚨 데이터 손실 수정 — `session close` 가 `current-state.md` 를 통째로 갈아치웠다
package/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.98 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.100 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.98는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.100는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.98는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.98 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.100 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.98: 2026-08-04
238
+ Last synced by Leerness v1.36.100: 2026-08-05
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.98';
37
+ const VERSION = '1.36.100';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -7575,6 +7575,10 @@ function _p0013LibraryOk() {
7575
7575
  W('src/Hero.astro', '<section class="p-4 rounded-lg bg-brand-500"></section>\n');
7576
7576
  W('src/theme.css', ':root { --brand-500: #2563eb; --radius-lg: 12px; }\n');
7577
7577
  W('src/inline.html', '<style>:root{--surface-1:#fff}</style><div class="px-3 py-1 rounded-lg"></div>\n');
7578
+ // leerness 가 사용자 저장소에 만들어 두는 산출물은 '이 앱의 것' 이 아니다 — 실측에서 대시보드 변수가 사용자 토큰으로 실렸고,
7579
+ // 시안이 존재하지 않는 토큰(var(--panel))을 쓰라고 지시할 뻔했다.
7580
+ W('leerness.html', '<style>:root{--panel:#111;--mut:#888}</style><div class="px-3 py-1 rounded-lg">dash</div>\n');
7581
+ W('roadmap.html', '<style>:root{--roadmapOnly:#222}</style>\n');
7578
7582
  // CSS 문맥 밖의 `--이름:` 은 토큰이 아니다 — 실측에서 주석 속 CLI 플래그(audit --fix: …)가 디자인 토큰으로 실렸다.
7579
7583
  W('src/docs.ts', '// audit --fix: 누락 키 자동 추가\n// health --strict: 모든 issue 시 exit 1\nexport const runFix = () => 1;\n');
7580
7584
  // 문자열·주석의 자리표시자는 태그처럼 생겼다 — 실측에서 UI 를 그리지 않는 감시 클래스 4개가 이것 때문에 컴포넌트로 실렸다.
@@ -7598,7 +7602,8 @@ function _p0013LibraryOk() {
7598
7602
  const brand = r.tokens.cssVars.find(v => v.name === 'brand-500');
7599
7603
  const tokOk = varNames.includes('brand-500') && varNames.includes('radius-lg') && !!brand && /#2563eb/.test(brand.value)
7600
7604
  && varNames.includes('surface-1') // <style> 블록 안도 CSS 문맥이다
7601
- && !varNames.includes('fix') && !varNames.includes('strict'); // 주석 속 CLI 플래그는 토큰이 아니다
7605
+ && !varNames.includes('fix') && !varNames.includes('strict') // 주석 속 CLI 플래그는 토큰이 아니다
7606
+ && !varNames.includes('panel') && !varNames.includes('mut') && !varNames.includes('roadmapOnly'); // 자기 산출물(leerness.html/roadmap.html)은 남의 것으로 세지 않는다
7602
7607
  // 빈도 문턱: 3회 이상만 싣는다(1~2회는 우연) — 문턱을 지우면 tracking-wide 가 섞인다
7603
7608
  const utilNames = r.tokens.utilities.map(u => u.name);
7604
7609
  const utilOk = utilNames.includes('rounded-lg') && utilNames.includes('bg-brand-500') && !utilNames.includes('tracking-wide');
@@ -8028,7 +8033,7 @@ function commandsCmd(root) {
8028
8033
  { cmd: 'tech [--json]', desc: '기술 프로필 — 개발 언어·연결 서비스 자동 감지 + 마이그레이션/언어전환 이력, 그래프 🛠 탭 표시 — 1.36.53 UR-0062' },
8029
8034
  { cmd: 'integrity check [--repair] [--json]', desc: 'managed 정책-문서 12종 무결성(부재/H1 상실/절단) 점검 — --repair: archive 대피 후 템플릿 재생성 — 1.36.57 감사 F-04' },
8030
8035
  { cmd: 'referee add|verify|list|show|drop', desc: '검증기 캘리브레이션 — 신뢰 전에 탐지력 증명(known-good 통과 + 망가뜨린 known-bad 를 기대 사유로 거부). verify-claim --referee / gate 게이팅 — 1.36.80 P-0001' },
8031
- { cmd: 'preview add|list|show|approve|revise|mockup', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--force]: 자립형 HTML 디자인 시안 스캐폴드 · add --mockup <파일>: 기존 시안 첨부 — 1.36.51 UR-0061 · 1.36.75 UR-0066' },
8036
+ { cmd: 'preview add|list|show|approve|revise|mockup|verify', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--target <파일>] [--force]: 프로젝트의 실제 토큰·컴포넌트·대상 화면 클래스를 심은 시안 · approve 시 그 시안이 계약으로 고정됨 · verify <P-ID> --files a,b: 구현이 계약을 지켰는지 존재 대조(시각적 일치는 보증하지 않음) — 1.36.51 UR-0061 · 1.36.75 UR-0066 · 1.36.99/1.36.100 P-0012' },
8032
8037
  { cmd: 'dashboard [path] [--port N] [--timeout SEC] [--json]', desc: '.harness 읽기 전용 대시보드 — 토글·task·마일스톤·미리보기·라우팅기록·bugfix probe 를 localhost 에서 열람. 설정 변경·게이트 판정은 하지 않는다(CLI 가 판정). --json 은 서버 없이 같은 스냅샷 출력. 정적 단일파일 뷰(leerness.html)는 leerness graph --html 로 그대로 — 1.36.90 P-0006' },
8033
8038
  { cmd: 'agents route "<작업>" [--tier tiny|normal|high-risk] [--confirm --approved-by "<승인자>"] [--log]', desc: '작업 난이도 판정(결정적 규칙) → 추상 역할(architect/commander/coder/reviewer) 배치 제안. 모델명 하드코딩 없이 `roles set` 매핑을 쓰고, 실행은 명시 확인 필요. 토글 기본 OFF: leerness toggle set difficulty-routing on — 1.36.89 P-0007' },
8034
8039
  { cmd: 'bugfix start|receipt|drop|list', desc: 'bugfix 완료 영수증 — 수정 전 재현 probe 를 등록(2회 연속 실패해야 등록)하고, done 전이에서 통과 + 근본원인/형제범위 영수증을 요구. 토글 기본 OFF: leerness toggle set bugfix-receipt on — 1.36.87 P-0005' },
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,86 @@ 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
+ // 1.36.100 (P-0012 후반): 승인된 시안을 '계약' 으로 고정한다.
132
+ // 프레임워크 프로젝트에서 렌더된 마크업을 컴포넌트 소스로 되돌릴 수는 없다 — 그래서 "자동 적용" 이라 부르지 않는다.
133
+ // 대신 시안이 약속한 **클래스와 토큰**을 저장해 두고, 구현이 끝난 뒤 그 약속이 지켜졌는지 기계로 대조한다.
134
+ // 한계는 분명히 적는다: 이건 '존재' 검사지 '시각적 일치' 검사가 아니다(있다고 해서 같아 보이는 건 아니다).
135
+ const DRAW_START = 'leerness:draw-start';
136
+ const DRAW_END = 'leerness:draw-end';
137
+ function _extractContract(html) {
138
+ const s = String(html || '');
139
+ const a = s.indexOf(DRAW_START), b = s.indexOf(DRAW_END);
140
+ if (a < 0 || b < 0 || b < a) return { ok: false, reason: 'draw 마커를 찾지 못함 — 시안에서 마커를 지웠거나 구버전 시안입니다' };
141
+ const area = s.slice(s.indexOf('-->', a) + 3, b);
142
+ const classes = new Set(); const tokens = new Set(); const tags = new Set();
143
+ let m;
144
+ const CLS = /class\s*=\s*["']([^"']{1,400})["']/g;
145
+ while ((m = CLS.exec(area))) for (const c of m[1].split(/\s+/)) if (c && !/^(?:placeholder|mock|notes|chips|sw|stack|meta)$/.test(c)) classes.add(c);
146
+ const TOK = /var\(\s*(--[\w-]+)\s*\)/g;
147
+ while ((m = TOK.exec(area))) tokens.add(m[1]);
148
+ const TAG = /<([a-zA-Z][\w-]*)/g;
149
+ while ((m = TAG.exec(area))) tags.add(m[1].toLowerCase());
150
+ // '아직 안 그렸다' 는 길이로 판정하면 안 된다 — 도구가 넣어 둔 안내 문구만으로도 120자를 넘는다(실측에서 이 판정이 틀렸다).
151
+ // 손대지 않았다는 확실한 신호는 그 자리에 도구의 placeholder 가 그대로 있다는 것이다.
152
+ const untouched = /class\s*=\s*["']placeholder["']/.test(area) || classes.size === 0;
153
+ return { ok: true, classes: [...classes], tokens: [...tokens], tags: [...tags], drawn: area.replace(/\s+/g, ' ').trim().length, untouched };
154
+ }
155
+ function _mockupScaffold(p, ctx) {
95
156
  const feats = (p.features || []).map(f => ` <li>${_esc(f)}</li>`).join('\n');
157
+ const c = ctx || { stack: [], tokens: { cssVars: [], utilities: [] }, components: [], target: null, notes: [] };
158
+ // 실제 CSS 변수를 그대로 심는다 — 시안이 이 앱의 색/간격으로 그려지도록.
159
+ const COLORISH = /^(?:#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/;
160
+ const varDecl = (c.tokens.cssVars || []).map(v => ` --${v.name}: ${String(v.value).replace(/[<>{};]/g, '')};`).join('\n');
161
+ const varChips = (c.tokens.cssVars || []).slice(0, 24).map(v => {
162
+ const sw = COLORISH.test(String(v.value).trim()) ? `<i class="sw" style="background:var(--${_esc(v.name)})"></i>` : '';
163
+ return `<li>${sw}<code>--${_esc(v.name)}</code></li>`;
164
+ }).join('\n');
165
+ 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');
166
+ const compChips = (c.components || []).map(x => `<li><code>${_esc(x.name)}</code><span class="n">${_esc(x.file)}</span></li>`).join('\n');
167
+ const tgt = c.target;
168
+ const tgtBlock = !tgt ? '' : (tgt.error
169
+ ? `<section class="mock"><h2>대상 화면</h2><p class="notes">${_esc(tgt.file)} — ${_esc(tgt.error)}</p></section>`
170
+ : `<section class="mock"><h2>대상 화면 — ${_esc(tgt.file)} (${tgt.lines}줄)</h2>
171
+ <p class="notes">이 화면이 <b>실제로 쓰는</b> 클래스와 요소다(소스에서 읽음 — 렌더 결과가 아니다). 새 시안은 여기서 벗어나지 않게 그린다.</p>
172
+ <ul class="chips">${(tgt.classes || []).map(x => `<li><code>${_esc(x.slice(0, 90))}</code></li>`).join('')}</ul>
173
+ <ul class="chips">${(tgt.tags || []).map(x => `<li><code>&lt;${_esc(x)}&gt;</code></li>`).join('')}</ul></section>`);
174
+ const notes = (c.notes || []).map(n => `<li>${_esc(n)}</li>`).join('\n');
96
175
  return `<!doctype html>
97
176
  <html lang="ko">
98
177
  <head>
@@ -100,6 +179,10 @@ function _mockupScaffold(p) {
100
179
  <meta name="viewport" content="width=device-width, initial-scale=1">
101
180
  <title>${_esc(p.id)} 시안 — ${_esc(p.title)}</title>
102
181
  <style>
182
+ /* 이 프로젝트에서 실제로 쓰이는 디자인 토큰 — 시안이 앱과 같은 색/간격으로 그려지게 심는다 */
183
+ :root {
184
+ ${varDecl}
185
+ }
103
186
  :root { --ink:#1a1a1a; --sub:#666; --line:#e5e5e5; --accent:#2563eb; --bg:#fafafa; }
104
187
  * { box-sizing:border-box; margin:0; }
105
188
  body { font-family:system-ui,-apple-system,'Segoe UI',sans-serif; color:var(--ink); background:var(--bg); }
@@ -112,6 +195,12 @@ function _mockupScaffold(p) {
112
195
  .placeholder { border:2px dashed #cbd5e1; border-radius:8px; padding:40px; text-align:center; color:#94a3b8; font-size:14px; }
113
196
  .notes { font-size:13px; color:var(--sub); line-height:1.7; }
114
197
  .notes li { margin-left:18px; }
198
+ ul.chips { list-style:none; margin:6px 0 0; padding:0; display:flex; flex-wrap:wrap; gap:5px; }
199
+ 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; }
200
+ ul.chips code { font-family:ui-monospace,Menlo,monospace; }
201
+ ul.chips .n { color:var(--sub); font-size:10.5px; }
202
+ .sw { width:12px; height:12px; border-radius:3px; border:1px solid var(--line); display:inline-block; }
203
+ .stack { font-size:12px; color:var(--sub); }
115
204
  </style>
116
205
  </head>
117
206
  <body>
@@ -127,9 +216,20 @@ function _mockupScaffold(p) {
127
216
  ${feats || ' <li>(없음 — --features "a,b" 로 기록)</li>'}
128
217
  </ul>
129
218
  </section>
219
+ ${tgtBlock}
220
+ <section class="mock">
221
+ <h2>이 앱의 재료 — 새로 만들지 말고 여기서 조립</h2>
222
+ <p class="stack">스택: ${_esc((c.stack || []).join(' · ') || '(미상)')}</p>
223
+ ${compChips ? `<p class="notes" style="margin-top:10px">이미 있는 컴포넌트 ${(c.components || []).length}개 — 같은 걸 또 만들지 마세요</p><ul class="chips">${compChips}</ul>` : '<p class="notes">추출된 컴포넌트 없음 — 아래 안내를 확인하세요</p>'}
224
+ ${varChips ? `<p class="notes" style="margin-top:10px">디자인 토큰(CSS 변수) — 위 :root 에 실제 값으로 심어 뒀으니 <code>var(--이름)</code> 으로 쓰세요</p><ul class="chips">${varChips}</ul>` : ''}
225
+ ${utilChips ? `<p class="notes" style="margin-top:10px">실제로 자주 쓰는 유틸 클래스(빈도순) — 이 어휘 안에서 그리세요</p><ul class="chips">${utilChips}</ul>` : ''}
226
+ ${notes ? `<p class="notes" style="margin-top:10px">인벤토리 한계</p><ul class="notes">${notes}</ul>` : ''}
227
+ </section>
130
228
  <section class="mock">
131
- <h2>화면 시안 — ⬇ AI 영역을 실제 레이아웃 초안으로 교체하세요</h2>
132
- <div class="placeholder">여기에 헤더 / 히어로 / 본문 / 푸터 실제 화면 구조의 HTML/CSS 초안을 그립니다.<br>외부 리소스 없이(오프라인) 이 파일 하나로 열리게 유지하세요.</div>
229
+ <h2>화면 시안 — ⬇ 아래 마커 <b>사이</b>만 실제 레이아웃 초안으로 교체하세요</h2>
230
+ <!-- leerness:draw-start · 승인 구간의 클래스와 var(--토큰) '계약' 으로 고정된다. 마커는 지우지 마세요. -->
231
+ <div class="placeholder">위의 <b>실제 토큰·컴포넌트·대상 화면 클래스</b>를 재료로 삼아 그립니다 — 새 색/새 간격을 발명하지 마세요.<br>외부 리소스 없이(오프라인) 이 파일 하나로 열리게 유지하세요.</div>
232
+ <!-- leerness:draw-end -->
133
233
  </section>
134
234
  </main>
135
235
  <!-- leerness:mockup ${_esc(p.id)} — 이 파일은 시안입니다. 승인(leerness preview approve ${_esc(p.id)}) 전 실제 코드 작성 금지. -->
@@ -228,19 +328,64 @@ function previewCmd(root, sub, rest, deps = {}) {
228
328
  ok(`시안 이미 존재: ${path.relative(root, mkFile)} — 내용 보호를 위해 덮어쓰지 않음 (재생성: --force) · 경로는 기록됨`);
229
329
  return;
230
330
  }
231
- writeUtf8(mkFile, _mockupScaffold(p));
331
+ // 1.36.99 (P-0012): 빈 스캐폴드 대신 이 앱의 재료(토큰·컴포넌트·대상 화면 클래스)를 심어 준다.
332
+ // --target <파일> 을 주면 그 화면이 실제로 쓰는 클래스/요소까지 함께 싣는다.
333
+ const _target = (arg && arg('--target', null)) || null;
334
+ if (_target) p.target = _target;
335
+ const _ctx = _mockupContext(root, p.target || null);
336
+ writeUtf8(mkFile, _mockupScaffold(p, _ctx));
232
337
  p.mockupPath = path.relative(root, mkFile).replace(/\\/g, '/');
233
338
  // (검수 Low) 재생성은 별도 이벤트로 — 이력 오해 방지
234
339
  (p.history = p.history || []).push({ at: now(), event: _regen ? 'mockup-regenerated' : 'mockup-created' });
235
340
  if (!_savePreviews(root, list, json)) return;
236
341
  if (json) { log(JSON.stringify({ ok: true, id: p.id, mockupPath: p.mockupPath, created: !_regen, regenerated: _regen }, null, 2)); return; }
237
342
  ok(`시안 ${_regen ? '재생성' : '스캐폴드 생성'}: ${p.mockupPath}`);
238
- log(` AI: placeholder 영역을 실제 레이아웃 초안(HTML/CSS)으로 교체하세요 (외부 리소스 없이).`);
343
+ log(` 재료: 컴포넌트 ${(_ctx.components || []).length} · CSS변수 ${(_ctx.tokens.cssVars || []).length} · 유틸 ${(_ctx.tokens.utilities || []).length}${_ctx.target ? ` · 대상 ${_ctx.target.file}` : ''}`);
344
+ if (!_ctx.target) log(` → 대상 화면을 지정하면 정확도가 올라갑니다: leerness preview mockup ${p.id} --target src/pages/Foo.tsx --force`);
345
+ log(` → AI: placeholder 영역을 실제 레이아웃 초안(HTML/CSS)으로 교체하세요 (외부 리소스 없이, 위 재료로 조립).`);
239
346
  log(` → 사용자에게 이 파일을 브라우저로 열어 보여주고 승인/수정을 질문으로 받으세요.`);
240
347
  log(` ⓘ 계약: approve 전에는 실제 기능 코드를 작성하지 않는다.`);
241
348
  return;
242
349
  }
243
350
 
351
+ // 1.36.100 (P-0012 후반): `preview verify <P-ID> --files a,b` — 승인 시 고정한 계약과 구현을 대조.
352
+ // 무엇을 하는가: 시안이 약속한 클래스/토큰이 구현 파일에 '나타나는가' 를 센다.
353
+ // 무엇을 하지 않는가: 시각적 일치를 보증하지 않는다. 클래스가 있다고 같아 보이는 건 아니다 —
354
+ // 이 구분을 흐리면 1.36.5 에서 고쳤던 과장을 되풀이하게 된다. 그래서 출력에 한계를 매번 적는다.
355
+ if (sub === 'verify') {
356
+ if (!id) { failJson(json, 'id_required', `preview verify <P-ID> --files a,b 필요`); return; }
357
+ if (!p) { failJson(json, 'not_found', `preview 없음: ${id}`); return; }
358
+ if (!p.contract) { failJson(json, 'no_contract', `${id} 에 계약이 없습니다 — 시안을 만들고(preview mockup) 승인(preview approve)해야 고정됩니다`); return; }
359
+ const filesArg = (arg && arg('--files', '')) || '';
360
+ const files = filesArg.split(',').map(s => s.trim()).filter(Boolean);
361
+ if (!files.length) { failJson(json, 'files_required', `--files src/a.tsx,src/b.css 로 구현 파일을 지정하세요`); return; }
362
+ const base = absRoot(root);
363
+ let text = ''; const read_ = []; const missingFiles = [];
364
+ for (const f of files) {
365
+ const fp = path.resolve(base, f);
366
+ if (fp !== base && !fp.startsWith(base + path.sep)) { missingFiles.push(`${f} (루트 밖 — 읽지 않음)`); continue; }
367
+ if (!exists(fp)) { missingFiles.push(`${f} (없음)`); continue; }
368
+ try { const st = fs.statSync(fp); if (st.size > 1024 * 1024) { missingFiles.push(`${f} (1MB 초과 — 건너뜀)`); continue; } text += '\n' + read(fp); read_.push(f); } catch (e) { missingFiles.push(`${f} (${e.message})`); }
369
+ }
370
+ const cls = p.contract.classes || [], tok = p.contract.tokens || [];
371
+ const clsFound = cls.filter(c => text.includes(c));
372
+ const tokFound = tok.filter(t => text.includes(t));
373
+ const total = cls.length + tok.length;
374
+ const found = clsFound.length + tokFound.length;
375
+ const pct = total ? Math.round(found * 100 / total) : 0;
376
+ const missCls = cls.filter(c => !clsFound.includes(c)), missTok = tok.filter(t => !tokFound.includes(t));
377
+ if (json) { log(JSON.stringify({ ok: true, id, filesRead: read_, filesSkipped: missingFiles, classes: { total: cls.length, found: clsFound.length, missing: missCls }, tokens: { total: tok.length, found: tokFound.length, missing: missTok }, presencePct: pct, limit: 'presence-only — 시각적 일치를 보증하지 않음' }, null, 2)); return; }
378
+ log(`# preview verify ${id} — 시안 계약 ↔ 구현 대조`);
379
+ log(` 읽은 파일 ${read_.length}개${missingFiles.length ? ` · 건너뜀 ${missingFiles.length}개` : ''}`);
380
+ missingFiles.forEach(x => warn(` ${x}`));
381
+ log(` 클래스 ${clsFound.length}/${cls.length} · 토큰 ${tokFound.length}/${tok.length} → 존재율 ${pct}%`);
382
+ if (missCls.length) log(` 누락 클래스: ${missCls.slice(0, 20).join(' ')}${missCls.length > 20 ? ` …외 ${missCls.length - 20}` : ''}`);
383
+ if (missTok.length) log(` 누락 토큰: ${missTok.slice(0, 20).join(' ')}${missTok.length > 20 ? ` …외 ${missTok.length - 20}` : ''}`);
384
+ if (!read_.length) warn(' 읽은 구현 파일이 없어 존재율은 의미가 없습니다');
385
+ log(` ⓘ 한계: 이건 '존재' 검사입니다 — 클래스·토큰이 나타나는지만 봅니다. 같아 보이는지는 보지 않습니다(그건 사람이 시안과 화면을 나란히 봐야 합니다).`);
386
+ return;
387
+ }
388
+
244
389
  if (sub === 'show') {
245
390
  if (json) { log(JSON.stringify({ ok: true, ...p }, null, 2)); return; }
246
391
  log(`# ${p.id} 「${p.title}」 [${p.status}]`);
@@ -259,10 +404,26 @@ function previewCmd(root, sub, rest, deps = {}) {
259
404
  ok(`${p.id} 이미 승인됨 — 변경 없음`); return;
260
405
  }
261
406
  p.status = 'approved'; (p.history = p.history || []).push({ at: now(), event: 'approved' });
407
+ // 1.36.100 (P-0012 후반): 승인 순간의 시안을 계약으로 고정한다 — 나중에 구현이 그 약속을 지켰는지 대조하기 위해서다.
408
+ // 시안이 없거나 마커가 지워졌으면 계약을 만들지 않는다(없는 계약을 있는 척 하지 않는다).
409
+ let _contractNote = '';
410
+ if (p.mockupPath) {
411
+ const mp = path.resolve(root, p.mockupPath);
412
+ if (exists(mp)) {
413
+ const ex = _extractContract(read(mp));
414
+ if (ex.ok) {
415
+ p.contract = { at: now(), classes: ex.classes, tokens: ex.tokens, tags: ex.tags, drawnChars: ex.drawn, untouched: !!ex.untouched };
416
+ _contractNote = `계약 고정: 클래스 ${ex.classes.length} · 토큰 ${ex.tokens.length} · 요소 ${ex.tags.length}`;
417
+ if (ex.untouched) _contractNote += ' ⚠ 시안을 아직 안 그렸습니다(placeholder 그대로) — 계약이 비어 대조가 무의미합니다';
418
+ } else _contractNote = `계약 미고정: ${ex.reason}`;
419
+ } else _contractNote = '계약 미고정: 시안 파일 없음';
420
+ } else _contractNote = '계약 미고정: 시안 없음 (leerness preview mockup 으로 먼저 생성)';
262
421
  if (!_savePreviews(root, list, json)) return;
263
422
  try { if (typeof deps._onResolved === 'function') deps._onResolved(p.id); } catch {} // 1.36.80 (UR-0067): 확인 완료 → 임시 워크스페이스 정리
264
- if (json) { log(JSON.stringify({ ok: true, id: p.id, status: p.status }, null, 2)); return; }
423
+ if (json) { log(JSON.stringify({ ok: true, id: p.id, status: p.status, contract: p.contract || null, contractNote: _contractNote }, null, 2)); return; }
265
424
  ok(`${p.id} 승인 — 이제 구현을 시작해도 됩니다.`);
425
+ log(` ${_contractNote}`);
426
+ if (p.contract) log(` → 구현 후 대조: leerness preview verify ${p.id} --files src/a.tsx,src/b.css`);
266
427
  return;
267
428
  }
268
429
  if (sub === 'revise') {
package/lib/library.js CHANGED
@@ -21,6 +21,10 @@ const SKIP_DIR_RE = /^(?:node_modules|\.git|\.next|_next|dist|build|out|coverage
21
21
  const UI_EXT_RE = /\.(?:jsx|tsx|vue|svelte|astro|js|mjs|cjs|ts|html?|css|scss|less)$/i;
22
22
  const TEST_RE = /(^|[\\/])(?:test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+|[^\\/]+\.stories\.[a-z]+)$|(^|[\\/])(?:tests?|__tests__)[\\/]/i;
23
23
  const MINIFIED_RE = /[.-]min\.[a-z]+$|\.bundle\.[a-z]+$|-[0-9a-f]{8,}\.[a-z]+$/i;
24
+ // leerness 가 사용자 저장소에 만들어 두는 산출물은 '이 앱의 것' 이 아니다 — 실측에서 leerness.html 의 대시보드 변수
25
+ // (--panel · --mut · --txt …)가 사용자 디자인 토큰으로 실렸고, 시안이 존재하지 않는 토큰을 쓰라고 지시할 뻔했다.
26
+ // 자기가 만든 것을 남의 것으로 세지 않는다.
27
+ const OWN_ARTIFACT_RE = /(^|[\\/])(?:leerness|roadmap|leerness-library|leerness-preview[^\\/]*)\.html$/i;
24
28
  const FILE_CAP = 1024 * 1024; // 파일당
25
29
  const TOTAL_CAP = 48 * 1024 * 1024; // 스캔 1회당 — 비용은 파일 하나가 아니라 호출 하나에 묶는다(1.36.97 교훈)
26
30
  const MAX_FILES = 6000;
@@ -74,7 +78,7 @@ function _walk(root, opts) {
74
78
  if (e.isSymbolicLink()) continue; // 링크는 따라가지 않는다(밖으로 새는 유일한 경로)
75
79
  if (e.isDirectory()) { if (!SKIP_DIR_RE.test(e.name)) rec(p, depth + 1); continue; }
76
80
  if (!e.isFile() || !UI_EXT_RE.test(e.name)) continue;
77
- if (TEST_RE.test(p) || MINIFIED_RE.test(e.name)) { stats.skippedGenerated++; continue; }
81
+ if (TEST_RE.test(p) || MINIFIED_RE.test(e.name) || OWN_ARTIFACT_RE.test(p)) { stats.skippedGenerated++; continue; }
78
82
  let st; try { st = fs.statSync(p); } catch { continue; }
79
83
  if (st.size > fileCap) { stats.skippedLarge++; continue; }
80
84
  if (st.size > budget) { stats.budgetExhausted = true; continue; }
@@ -294,4 +298,4 @@ function libraryCmd(root, sub, deps = {}) {
294
298
  log(' 사람용 페이지: leerness library page · 에이전트용 압축: leerness library --ai');
295
299
  }
296
300
 
297
- module.exports = { scanLibrary, compactLibrary, renderLibraryPage, libraryCmd, _walk, _components, _tokens, _cssSources, _hasMarkupNear, _tokenSource, _esc, SKIP_DIR_RE, UI_EXT_RE, AI_CAPS };
301
+ module.exports = { scanLibrary, compactLibrary, renderLibraryPage, libraryCmd, _walk, _components, _tokens, _cssSources, _hasMarkupNear, _tokenSource, _esc, SKIP_DIR_RE, UI_EXT_RE, OWN_ARTIFACT_RE, AI_CAPS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.98",
3
+ "version": "1.36.100",
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
@@ -3744,7 +3744,11 @@ total++;
3744
3744
  {
3745
3745
  let ok = false;
3746
3746
  try {
3747
- const r = cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { encoding: 'utf8', timeout: 30000 });
3747
+ // 1.36.100: 호출만 30초로 남아 있었다 — doctor selftest 내장해 격리 실측 31s(게시된 1.36.98 도 동일)
3748
+ // 전체 스위트 부하에서 간헐 초과했고, 실제로 게이트 한 번을 실패시켰다(내 변경 때문이 아니라 잠복 결함).
3749
+ // 같은 명령의 다른 호출(4644·6955)은 1.36.87 에서 이미 120초로 올려 뒀다 — 여기만 빠져 있었다.
3750
+ // 검사 대상은 속도가 아니라 동작이다.
3751
+ const r = cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { encoding: 'utf8', timeout: 120000 });
3748
3752
  let j = null; try { j = JSON.parse(r.stdout); } catch {}
3749
3753
  const doctorOk = j && j.version && typeof j.mcpTools === 'number' && j.mcpTools >= 80 && j.selftest && j.selftest.total > 0 && j.healthy === true && r.status === 0;
3750
3754
  // commands 요약 + banner 가 실제 MCP 수 노출 (하드코딩 65/46 아님)
@@ -9528,6 +9532,93 @@ total++;
9528
9532
  if (!ok) failed++;
9529
9533
  }
9530
9534
 
9535
+ // ── 1.36.99 (P-0012): 시안 정확도 — 시안이 '빈 종이' 가 아니라 이 앱의 재료로 시작하는가.
9536
+ {
9537
+ total++;
9538
+ let ok = false; const dbg = {};
9539
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mk99-'));
9540
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9541
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9542
+ try {
9543
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'shop', version: '0.1.0', dependencies: { react: '18', next: '14', tailwindcss: '3' } }));
9544
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9545
+ const W = (rel, t) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, t); };
9546
+ W('src/theme.css', ':root { --brand-500: #2563eb; --surface-1: #0f1115; }\n');
9547
+ W('src/components/Button.tsx', 'export function Button(){ return <button className="px-3 py-1 rounded-lg bg-brand-500">go</button> }\n');
9548
+ W('src/components/Card.tsx', 'export function Card(){ return <div className="p-4 rounded-lg bg-surface-1">x</div> }\n');
9549
+ 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');
9550
+ // 자기 산출물을 픽스처에 명시적으로 둔다 — 없으면 아래 noOwn 단언이 공허하게 통과한다(실제로 그렇게 살아남았다).
9551
+ // e2e 는 LEERNESS_NO_AUTO_ROADMAP=1 로 돌아 대시보드가 자동 생성되지 않으므로 직접 쓴다.
9552
+ W('leerness.html', '<style>:root{--panel:#111;--mut:#888}</style><div class="px-3 py-1 rounded-lg">dash</div>\n');
9553
+ const id = (/(P-\d{4,})/.exec(out(R(['preview', 'add', '장바구니 개편', '--design', '기존 카드 재사용']))) || [])[1];
9554
+ const mk = out(R(['preview', 'mockup', id, '--target', 'src/pages/Cart.tsx']));
9555
+ const html = fs.readFileSync(path.join(d, '.harness', 'previews', id + '-mockup.html'), 'utf8');
9556
+ const materials = /--brand-500:\s*#2563eb/.test(html) && /Button/.test(html) && /Card/.test(html) && /rounded-lg/.test(html) && /tailwind/.test(html);
9557
+ const targeted = /Cart\.tsx/.test(html) && /p-6 rounded-lg bg-surface-1/.test(html) && /&lt;main&gt;/.test(html);
9558
+ // leerness 가 만든 대시보드 변수를 사용자 토큰인 척 싣지 않는다(실측: 실제 프로젝트마다 3~10개 유령 토큰이 있었다)
9559
+ const noOwn = !/--panel\s*:/.test(html) && !/--mut\s*:/.test(html);
9560
+ const offline = !/(?:src|href)\s*=\s*["'](?!#)/i.test(html) && !/<script/i.test(html);
9561
+ // 대상이 루트 밖이면 읽지 않는다
9562
+ R(['preview', 'mockup', id, '--target', '../../etc/passwd', '--force']);
9563
+ const esc = fs.readFileSync(path.join(d, '.harness', 'previews', id + '-mockup.html'), 'utf8');
9564
+ const guarded = /루트 밖/.test(esc);
9565
+ const reports = /재료: 컴포넌트 \d+/.test(mk);
9566
+ Object.assign(dbg, { materials, targeted, noOwn, offline, guarded, reports, len: html.length });
9567
+ ok = materials && targeted && noOwn && offline && guarded && reports;
9568
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9569
+ console.log(ok ? '✓ B(1.36.99/P-0012) 시안 정확도: 실제 토큰 :root 주입 + 기존 컴포넌트 어휘 + 대상 화면 실클래스 + 자기산출물 오염 0 + 오프라인 + 루트밖 거부'
9570
+ : '✗ 1.36.99 시안 정확도 실패 ' + JSON.stringify(dbg));
9571
+ if (!ok) failed++;
9572
+ }
9573
+
9574
+ // ── 1.36.100 (P-0012 후반): 승인 시 시안을 계약으로 고정 → 구현 후 존재 대조. 과장하지 않는 것까지 함께 건다.
9575
+ {
9576
+ total++;
9577
+ let ok = false; const dbg = {};
9578
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ct100-'));
9579
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9580
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9581
+ try {
9582
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'shop', version: '0.1.0', dependencies: { react: '18', tailwindcss: '3' } }));
9583
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9584
+ const W = (rel, t) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, t); };
9585
+ W('src/theme.css', ':root { --brand-500: #2563eb; --surface-1: #0f1115; }\n');
9586
+ W('src/components/Button.tsx', 'export function Button(){ return <button className="px-3 py-1 rounded-lg">go</button> }\n');
9587
+ const id = (/(P-\d{4,})/.exec(out(R(['preview', 'add', '장바구니', '--design', 'x']))) || [])[1];
9588
+ R(['preview', 'mockup', id]);
9589
+ const mp = path.join(d, '.harness', 'previews', id + '-mockup.html');
9590
+ // 안 그린 시안을 승인하면 계약이 비었다고 말한다(조용히 빈 계약을 만들지 않는다)
9591
+ const ap0 = out(R(['preview', 'approve', id]));
9592
+ const warnsEmpty = /아직 안 그렸습니다/.test(ap0);
9593
+ // AI 가 마커 사이를 채운 뒤 재승인 → 계약 고정
9594
+ R(['preview', 'revise', id, '--note', '초안 반영']);
9595
+ let html = fs.readFileSync(mp, 'utf8');
9596
+ const drawn = '\n<header class="topbar p-4 rounded-lg" style="background:var(--surface-1)"><h1 class="text-xl">장바구니</h1></header>\n<ul class="cart-list gap-2"><li class="cart-item px-3 py-1" style="border-color:var(--brand-500)">항목</li></ul>\n';
9597
+ fs.writeFileSync(mp, html.replace(/(leerness:draw-start[^>]*-->)[\s\S]*?(<!-- leerness:draw-end)/, `$1${drawn}$2`));
9598
+ const ap = out(R(['preview', 'approve', id]));
9599
+ const fixed = /계약 고정: 클래스 [1-9]/.test(ap);
9600
+ const store = JSON.parse(fs.readFileSync(path.join(d, '.harness', 'previews.json'), 'utf8'));
9601
+ const rec = ((store.previews || store) || []).find(x => x.id === id);
9602
+ const stored = !!(rec && rec.contract && rec.contract.classes.includes('topbar') && rec.contract.tokens.includes('--brand-500'))
9603
+ && !rec.contract.classes.some(c => ['mock', 'notes', 'chips', 'placeholder'].includes(c)); // 스캐폴드 장식은 계약이 아니다
9604
+ // 미준수 구현 → 낮은 존재율 · 준수 구현 → 100%
9605
+ W('src/pages/Cart.tsx', 'export function Cart(){ return <main className="p-4">x</main> }\n');
9606
+ const low = Number((/존재율 (\d+)%/.exec(out(R(['preview', 'verify', id, '--files', 'src/pages/Cart.tsx']))) || [, '100'])[1]);
9607
+ W('src/pages/Cart.tsx', 'import "./cart.css";\nexport function Cart(){ return (<main><header className="topbar p-4 rounded-lg"><h1 className="text-xl">장바구니</h1></header><ul className="cart-list gap-2"><li className="cart-item px-3 py-1">항목</li></ul></main>) }\n');
9608
+ W('src/pages/cart.css', '.topbar{background:var(--surface-1)} .cart-item{border-color:var(--brand-500)}\n');
9609
+ const v2 = out(R(['preview', 'verify', id, '--files', 'src/pages/Cart.tsx,src/pages/cart.css']));
9610
+ const high = Number((/존재율 (\d+)%/.exec(v2) || [, '0'])[1]);
9611
+ // 과장 금지: '존재' 검사라는 한계를 매번 말한다(1.36.5 에서 고쳤던 과장의 재발 방지)
9612
+ const honest = /'존재' 검사입니다/.test(v2) && /같아 보이는지는 보지 않습니다/.test(v2);
9613
+ const guarded = /루트 밖/.test(out(R(['preview', 'verify', id, '--files', '../../etc/passwd'])));
9614
+ Object.assign(dbg, { warnsEmpty, fixed, stored, low, high, honest, guarded });
9615
+ ok = warnsEmpty && fixed && stored && low < 50 && high === 100 && honest && guarded;
9616
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9617
+ console.log(ok ? '✓ B(1.36.100/P-0012) 승인 시 시안→계약 고정 + 구현 존재 대조(미준수 저율/준수 100%) + 한계 고지 + 루트밖 거부 + 빈 시안 경고'
9618
+ : '✗ 1.36.100 시안 계약 실패 ' + JSON.stringify(dbg));
9619
+ if (!ok) failed++;
9620
+ }
9621
+
9531
9622
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
9532
9623
  if (failed > 0) process.exit(1);
9533
9624