@zalkera/client 0.16.0 → 0.17.0

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.
@@ -0,0 +1,1607 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 잘커라 스토어프론트 validator (MVP).
4
+ *
5
+ * AI 가 만든 스토어프론트에서 흔한 보안·정합 실수를 정적으로 잡는다(llms.txt §5). CI 에 걸어
6
+ * 회귀를 막는다. 사용: `node scripts/validate-storefront.mjs [srcDir]` (기본 ./src).
7
+ *
8
+ * 검사:
9
+ * E1 "use client" 파일에서 @zalkera/client 를 값으로 import → baseUrl/토큰 노출 위험.
10
+ * E2 "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera) import → 같은 위험.
11
+ * W1 클라이언트 싱글턴 파일이 하나도 없음 → 서버 사이드 호출 패턴 미구현 의심.
12
+ * C1 ISR-우선 게이트(memo31 §0-12) — SEO 라우트 page 가 per-page SSR(동적 렌더)을 강제하면 실패.
13
+ * codegen 산출물이 홈·목록·상세·콘텐츠 페이지를 동적SSR 로 만들면 CI 를 red 로 만들어 미배포.
14
+ * 정당화된 예외는 파일에 `// zalkera-allow-dynamic: <이유>` 마커를 두면 경고로 강등된다.
15
+ * C1b layout 폭발반경 게이트 — layout/template 이 **import 로 도달하는 서버 모듈**에서 동적 API 를
16
+ * 쓰면 실패. C1 과 달리 파일 하나가 아니라 import 그래프를 본다.
17
+ * S1 error — .tsx 에서 죽은 레거시 토큰 `var(--oneq-` 참조. → `bg-primary`/`text-primary` 유틸리티로.
18
+ * S2 warning — JSX 인라인 `style={{`(CSS 변수 주입 `style={{"--` 은 면제). 스타일은 유틸리티 클래스로.
19
+ * S3 error — src/app/globals.css 부재 또는 root layout 이 그걸 import 하지 않음(배선 회귀 방지).
20
+ * S4 warning — className 색 임의값(`bg-[#`·`text-[#`·`border-[#`). 테넌트 색은 토큰 경유가 규약.
21
+ * S5 warning — src/app/globals.css 외의 .css 파일 존재(단일 CSS 원칙).
22
+ * C2 error — 섹션 렌더러 switch 가 계약(@zalkera/client SECTION_CONTRACT)을 덮지 못함. 계약에 있는
23
+ * 타입을 렌더러가 모르면 그 섹션은 **조용히 안 그려진다**(미지 타입 스킵이 계약이라 에러도 안 난다).
24
+ * S8 error — **표현 계약(L1) 배선 부재**(declared 전용·memo109). globals.css 의 `@theme`+`--color-primary`
25
+ * 정의와 root layout 의 테마 주입(`parseThemeColors(` 호출 + `<html>` style)을 센다. 이게 없으면 콘솔의
26
+ * "말로 색 바꾸기"가 **성공 보고를 내고 화면은 그대로**인 거짓성공이 된다 — validator 가 여태 L1 의
27
+ * 심장을 한 번도 안 봤다(memo107 §4.2 가 '거짓 양성의 잔여'로 인정하고 미뤄 둔 자리).
28
+ * S6 error — 남의 토큰 어휘(shadcn 기본 변수명) 클래스 사용. shadcn 소스를 발췌해 올 때 재작성 표를
29
+ * 적용하지 않으면 `bg-card`·`text-muted-foreground` 같은 **정의되지 않은 토큰**을 참조해 색이 조용히
30
+ * 빠진다. 우리 @theme 이 토큰 정본이고 shadcn 변수층은 반입하지 않는다(memo102 §4.1).
31
+ * N1~N5 — **콘텐츠 파일 계약**(어휘 계약 rev 4 `contentFile` · 선언 `zalkera.content`). 사이트의
32
+ * 얼굴(페이지·섹션·문구·이미지 선택·내비)이 `content/` 아래 json 으로 살 때, 그 파일이 조용히
33
+ * 안 그려지는 형상을 잡는다. 아래 '콘텐츠 조건화' 참고 — **선언한 레포에서만 error** 다.
34
+ *
35
+ * ── 스택 조건화(memo75) ──────────────────────────────────────────
36
+ * E1/E2/W1(헤드리스 계약·스택무관)·C1/C1b(Next App Router 전제)는 **상시** 적용한다.
37
+ * S1~S5(Tailwind+토큰 전제)는 레포의 스택 선언에 따라 3모드로 게이팅한다(선두에서 1회 판정):
38
+ *
39
+ * 모드 판정 S1 S2 S3 S4 S5
40
+ * declared package.json zalkera.styling === "tailwind-tokens" E E E E W (토큰 계약 모드)
41
+ * inferred 선언 부재 + deps/devDeps 에 tailwindcss 있음 W W W W W (위생 모드)
42
+ * none 그 외(다른 선언값·tailwindcss 도 없음) – – – – – (스킵)
43
+ *
44
+ * ── 콘텐츠 조건화(memo129 §1.3) ─────────────────────────────────
45
+ * 같은 3모드를 `content` 축에 그대로 적용한다. **계약을 안 지킨 레포도 돌아야 한다**(오너 정본 전제 1
46
+ * "강제할 수 없다") — 문구를 tsx 에 직접 든 레포도 개시·발행·codegen 이 전부 정상이고, 검사는 레포가
47
+ * **스스로 선언했을 때만** 격상된다.
48
+ *
49
+ * 모드 판정 N1~N5
50
+ * declared package.json zalkera.content === "source" E (콘텐츠 계약 모드)
51
+ * inferred 선언 부재 + content/pages/*.json 이 실재 W (형상은 있는데 선언이 없다)
52
+ * none 그 외(다른 선언값 · content 디렉터리 없음) – (스킵)
53
+ *
54
+ * N1 content/index.ts(매니페스트) 부재 — 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다.
55
+ * N2 content/pages/*.json 파싱 실패 또는 최상위가 객체 아님.
56
+ * N3 매니페스트와 파일의 어긋남 — 파일은 있는데 매니페스트에 없으면 **그 페이지는 존재하지 않는다**
57
+ * (라우트도 sitemap 도 모른다). 반대로 매니페스트만 있고 파일이 없으면 빌드가 깨진다.
58
+ * N4 섹션 형상 — `sections` 가 배열이 아님 · `type` 이 문자열 아님 · 계약에 없는 타입(렌더러가
59
+ * 조용히 스킵한다) · `config` 가 객체 아님 · **`sortOrder` 잔존**(순서 축 이중화 — 배열이 순서다).
60
+ * N5 참조 무결 — 에셋이 `public/` 루트 절대 경로가 아니거나 그 파일이 실재하지 않음 · 계약이 필수로
61
+ * 선언한 참조(`requiredRefs`)를 안 가리킴 · **id 형 키 직기입**(`assetId`·`productIds` — 숫자 id 는
62
+ * 테넌트 스코프라 소스에 적으면 그 소스가 다른 테넌트에서 의미를 잃는다).
63
+ * 상품 `handle` 이 실재하는지는 **여기서 못 판정한다** — 카탈로그는 DB(레인 B)에 있다. 그 축의
64
+ * 잣대는 산출물(개시된 사이트)이지 소스가 아니다.
65
+ *
66
+ * D1 AGENTS.md 가 **없는 파일을 가리킴**. codegen 이 가장 먼저 읽는 문서라 죽은 좌표는 곧 탐색 토큰이다
67
+ * (2026-07-30 기준선 실측: 낡은 좌표 때문에 에이전트가 콘텐츠 계약 대신 라우트를 새로 짰다).
68
+ * D2 설치된 `@zalkera/client` 의 llms.txt 가 **본보기로 지목한 경로**가 이 레포에 없음 — 레시피가
69
+ * 실물을 앞지른 상태(memo125 요건 5). **본보기 레포에서만** 돈다.
70
+ * D1·D2 는 우리 계약(styling·content 중 하나)을 선언한 레포에서 error, 그 밖에선 warning 이다.
71
+ *
72
+ * declared 는 Managed 토큰 계약 라인이라 S2/S4 를 error 로 격상한다(리터럴 색·인라인 style 이
73
+ * '말로 색 바꾸기'를 무력화하는 라인). inferred(우리 계약 미선언 Tailwind 레포)는 경고까지만,
74
+ * none(vanilla·bootstrap 등)은 인라인·리터럴이 그 레포의 정상이라 S 전부 스킵한다.
75
+ * 마커(zalkera-allow-inline-style·zalkera-allow-dynamic — 구 oneque-/oneq- 도 수용)·CSS변수 주입
76
+ * 면제는 모든 모드에서 유지.
77
+ */
78
+ /**
79
+ * ── 이 파일의 거처 (2026-08-01 이관) ──────────────────────────────────────────
80
+ *
81
+ * 종전에는 `zalkera-storefront-examples/scripts/` 에만 있었다. 그런데 **서빙 빌드가 이 검사기를
82
+ * 돌려야** 하는 자리가 생겼고(우리가 서빙하는 사이트에서 시크릿 노출·동적 SSR 강제를 막는다 —
83
+ * memo140 §6.5), 그러려면 컨테이너가 이 파일을 가져야 한다.
84
+ *
85
+ * 서빙 이미지에 사본을 굽는 길은 **정본을 둘로 만든다**. 그 병은 이 코드베이스가 하루에도 몇 번씩
86
+ * 겪는 것이다(계약 rev 가 올랐는데 검사기가 옛 키를 보던 일, 같은 관례를 두 곳이 다르게 구현하던 일).
87
+ * 그래서 **`@zalkera/client` 가 bin 으로 배송한다** — `zalkera-aeo-check` 가 이미 그 자리에 있고
88
+ * (memo123 §6.1), 소스 검사기도 같은 자리에 두면 정본이 하나로 남는다.
89
+ *
90
+ * 부수 효과가 하나 더 있다: **고객이 자기 손으로 같은 검사기를 돌릴 수 있다.** 종전에는 우리 예제
91
+ * 레포에만 있어서, 자기 소스를 받아 고치는 사람이 쓸 방법이 없었다.
92
+ *
93
+ * npx zalkera-validate ./src # 권고 — 위반을 알려 주되 막지 않는다
94
+ * npx zalkera-validate ./src --gate # 관문 — 우리가 서빙 책임을 지는 자리에서만 쓴다
95
+ *
96
+ * 이 파일은 **인자로 받은 소스 디렉터리 기준으로만** 동작한다(레포 고정 경로 0). 그래서 어느
97
+ * 체크아웃에서든, 압축을 푼 zip 안에서든 똑같이 돈다.
98
+ */
99
+ import {existsSync, readdirSync, readFileSync, statSync} from "node:fs";
100
+ import {createRequire} from "node:module";
101
+ import {basename, dirname, join, relative, resolve, sep} from "node:path";
102
+
103
+ /**
104
+ * 검사할 **소스 루트**(`src/`). 세 가지를 흡수한다 — 셋 다 실제로 사람이 치는 형태다.
105
+ *
106
+ * ⚠ 종전에는 `process.argv[2]` 를 그대로 썼다. 그래서 **`zalkera-validate --gate`**(관문 배선의 기본
107
+ * 형태)가 `--gate` 를 디렉터리로 잡고 "디렉터리를 찾을 수 없습니다"로 죽었다 — 검사를 한 줄도 안 돌린
108
+ * 채로. 플래그를 걸러 첫 **비플래그** 인자만 경로로 본다.
109
+ *
110
+ * ⚠ `.`(레포 루트)도 흡수한다. 종전에는 레포 루트를 소스 루트로 오인해 `app/globals.css` 가 없다고
111
+ * **거짓 오류**를 냈다 — 멀쩡한 파일을 없다고 말하는 쪽이 안 도는 것보다 나쁘다. 준 자리에 `app/` 이
112
+ * 없고 `src/app/` 이 있으면 `src/` 로 내려간다.
113
+ */
114
+ function resolveSourceRoot() {
115
+ const given = process.argv.slice(2).find((a) => !a.startsWith("-"));
116
+ if (!given) return "./src";
117
+ // 이미 소스 루트면 그대로. 아니면 그 아래 src/ 가 소스 루트인지 본다.
118
+ if (existsSync(join(given, "app"))) return given;
119
+ if (existsSync(join(given, "src", "app"))) return join(given, "src");
120
+ return given; // 둘 다 아니면 준 대로 두고 아래 존재 검사가 말하게 한다
121
+ }
122
+
123
+ const root = resolveSourceRoot();
124
+
125
+ /**
126
+ * shadcn 기본 토큰 어휘(재작성 표의 좌변·memo102 §4.1). 우리 @theme 에 없는 이름이라 클래스가 생성되지
127
+ * 않는다 — 색이 빠진 채로 조용히 배포되는 종류의 사고라 declared 모드에서 error 다.
128
+ * 주의: 우리 `muted` 는 **글자색**이라 `bg-muted`(shadcn 은 배경)와 의미가 다르다.
129
+ */
130
+ const FOREIGN_TOKEN_CLASSES = [
131
+ "bg-card", "bg-popover", "bg-muted", "bg-accent", "bg-destructive",
132
+ "text-card-foreground", "text-popover-foreground", "text-muted-foreground", "text-accent-foreground",
133
+ "text-destructive", "ring-ring", "border-input",
134
+ ];
135
+ const errors = [];
136
+ const warnings = [];
137
+ const layoutFiles = [];
138
+ const cssFiles = [];
139
+ let singletonFound = false;
140
+
141
+ /**
142
+ * 스타일 규약 모드 판정(memo75 §5) — validator 선두에서 1회. srcDir 상위 최근접 package.json 의
143
+ * `zalkera.styling` 선언을 읽는다(구 `oneque.styling` 도 수용 — 리네임 이행기):
144
+ * declared : zalkera.styling === "tailwind-tokens" → 토큰 계약 모드(S2/S4 error 격상)
145
+ * inferred : 선언 부재 + deps/devDeps 에 tailwindcss 있음 → 위생 모드(S 전부 warning)
146
+ * none : 그 외(다른 선언값·tailwindcss 도 없음) → S 전부 스킵
147
+ * package.json 을 못 찾거나 파싱 실패하면 none(안전 — S 안 들이댄다).
148
+ * 백엔드는 스택을 모른다 — 선언은 레포 안에 살고 validator 가 현장에서 읽는다(memo75 §2).
149
+ */
150
+ function detectStyleMode(srcDir) {
151
+ let dir = resolve(srcDir);
152
+ for (let i = 0; i < 12; i++) {
153
+ const pkgPath = join(dir, "package.json");
154
+ try {
155
+ if (statSync(pkgPath).isFile()) {
156
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
157
+ const styling = pkg?.zalkera?.styling ?? pkg?.oneque?.styling;
158
+ if (styling === "tailwind-tokens") return "declared";
159
+ if (styling !== undefined) return "none"; // 검사 규칙 없는 다른 선언값 = 잘커라 스타일 규약 없음
160
+ const deps = {...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {})};
161
+ return Object.prototype.hasOwnProperty.call(deps, "tailwindcss") ? "inferred" : "none";
162
+ }
163
+ } catch {
164
+ /* 부재·파싱 실패 — 상위 디렉터리로 */
165
+ }
166
+ const parent = dirname(dir);
167
+ if (parent === dir) break;
168
+ dir = parent;
169
+ }
170
+ return "none";
171
+ }
172
+
173
+ /**
174
+ * **관문 모드** — 이 검사기가 권고인지 관문인지.
175
+ *
176
+ * 기준(오너 확정 2026-08-01): **검사기는 권고하고, 우리가 책임지는 자리에서만 막는다.**
177
+ *
178
+ * 근거는 "선언했는가"도 "우리 어휘를 썼는가"도 아니라 **누가 서빙하는가**다. 실측으로 갈린다 —
179
+ * 백엔드는 `Origin` 을 **안 본다**(테넌트를 시크릿 키로 정한다). 즉 사이트의 CSRF 가 뚫려도
180
+ * **우리 백엔드는 안 뚫리고 그 사이트의 방문자가 다친다.** 자체 호스팅(BYO)이면 그건 그들의 위험이고
181
+ * 우리가 막을 근거가 없다(요건 1 — 어휘를 강제할 수 없다). 우리가 서빙하면 우리 박스에서 우리가
182
+ * 주입한 키를 들고 도는 것이라 운영 책임이 우리에게 온다 — 그 자리에서만 막는다.
183
+ *
184
+ * 그래서 도구가 모드를 정한다:
185
+ * · `npm run validate`(개발자가 자기 손으로) → **권고**. X·C·E 가 경고로 내려간다.
186
+ * · 팩 게이트(우리 예제·프리셋) → **관문**. 우리 자산이다.
187
+ * · `verify-zip`(우리가 서빙할 납품물 검수) → **관문**. 서빙 책임을 지는 자리다.
188
+ *
189
+ * 종전에는 셋이 갈라져 있었다 — 발주 스펙은 "선언 없으면 경고", `verify-zip` 주석은 "선언 없으면
190
+ * 스킵", 실제 동작은 "선언 무관 error". 세 문서가 서로 다른 약속을 하고 있었고 이 모드가 그것을 하나로 합친다.
191
+ */
192
+ const GATE_MODE = process.argv.includes("--gate") || process.env.ZALKERA_VALIDATE_GATE === "1";
193
+
194
+ /**
195
+ * 서빙 책임 축(X 교차사이트 가드 · C 동적 렌더 강등 · E 시크릿 노출)의 목적지.
196
+ * 관문 모드면 error, 아니면 경고. **선언 여부와 무관하다** — 이 축의 근거는 선언이 아니라 서빙이다.
197
+ */
198
+ function servingSink() {
199
+ return GATE_MODE ? errors : warnings;
200
+ }
201
+
202
+ /**
203
+ * **교차사이트 가드 축(X)의 목적지 — 관문 모드에서도 경고다.**
204
+ *
205
+ * 서빙 책임 축이면서도 `servingSink` 와 갈라 두는 이유는, **X1 이 보안이 아니라 이름을 재기 때문**이다.
206
+ * 양쪽으로 다 틀린다(심의 실측):
207
+ * · **거짓 음성** — 라우트 안에 동명 `function assertSameOrigin(){return null}` 을 선언하면 통과한다.
208
+ * 인자로 엉뚱한 `Request` 를 넘겨도 통과한다. 즉 **통과가 안전을 뜻하지 않는다.**
209
+ * · **거짓 양성** — 상용 서빙 중인 두 사이트(bix·credium)가 **둘 다 걸린다**(실측 2/2). 둘 다 문의
210
+ * 라우트 하나이고, reCAPTCHA + rate limit 으로 같은 위협을 이미 막고 있다.
211
+ *
212
+ * 통과가 안전을 뜻하지 않고 실패가 위험을 뜻하지 않는 검사를 관문에 놓으면, 얻는 것은 심리적 안심뿐이고
213
+ * 잃는 것은 신뢰다 — 게이트의 첫 동작이 **상용 전량 중단**이 된다.
214
+ *
215
+ * 그리고 마커(`zalkera-allow-cross-origin`)로 예외를 트는 길은 겉보기보다 나쁘다: 다는 순간 그 라우트는
216
+ * **영영 무검사**가 된다. 경고로 두면 매번 눈에 밟혀 언젠가 고쳐지지만, 마커는 침묵을 가르친다.
217
+ *
218
+ * ⚠ **이것은 X 축을 포기한다는 뜻이 아니다.** 재는 방법을 바꿔야 한다는 뜻이다 — "우리 심볼을 썼는가"가
219
+ * 아니라 "교차 오리진을 실제로 막는가"로. 그 판정은 정적 분석의 한계가 분명해 별도 설계 대상이고,
220
+ * 그때까지 이 축은 **크게 보이는 경고**로 남는다.
221
+ *
222
+ * 반면 `E`(시크릿이 브라우저 번들에 실림)·`C`(SEO 라우트가 동적 SSR 강제)는 **사실을 잰다** — 오탐 여지가
223
+ * 없고 상용 실측도 0건이라, 관문으로 켜도 아무도 안 막힌다. 그래서 그 둘만 `servingSink` 를 쓴다.
224
+ */
225
+ function crossOriginSink() {
226
+ return warnings;
227
+ }
228
+
229
+ const STYLE_MODE = detectStyleMode(root);
230
+
231
+ /**
232
+ * 콘텐츠 규약 모드 판정(memo129 §1.3) — [detectStyleMode] 와 **같은 모양**이다.
233
+ *
234
+ * 다른 점 하나: 추론의 근거가 의존성이 아니라 **형상**이다(`content/pages/*.json` 실재). 콘텐츠는
235
+ * 패키지로 안 오므로 deps 로는 알 수 없고, 그 형상을 갖췄다는 것 자체가 "이 계약을 쓰는 중"의 신호다.
236
+ * 선언하지 않은 레포를 error 로 막지 않는 이유는 스타일 축과 같다 — 전제 1("강제할 수 없다").
237
+ *
238
+ * declared : zalkera.content === "source" → N 규칙 error
239
+ * inferred : 선언 부재 + content/pages/*.json → N 규칙 warning
240
+ * none : 그 외 → N 규칙 스킵
241
+ */
242
+ function detectContentMode(srcDir) {
243
+ const repoRoot = resolve(srcDir, "..");
244
+ let declared;
245
+ let dir = resolve(srcDir);
246
+ for (let i = 0; i < 12; i++) {
247
+ const pkgPath = join(dir, "package.json");
248
+ try {
249
+ if (statSync(pkgPath).isFile()) {
250
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
251
+ declared = pkg?.zalkera?.content ?? pkg?.oneque?.content;
252
+ break;
253
+ }
254
+ } catch {
255
+ /* 부재·파싱 실패 — 상위 디렉터리로 */
256
+ }
257
+ const parent = dirname(dir);
258
+ if (parent === dir) break;
259
+ dir = parent;
260
+ }
261
+ if (declared === "source") return "declared";
262
+ if (declared !== undefined) return "none"; // sections-db 등 — 이 레포에 콘텐츠 파일 계약이 없다
263
+ return contentPageFiles(repoRoot).length > 0 ? "inferred" : "none";
264
+ }
265
+
266
+ /** `content/pages/*.json` 전량(정렬). 없으면 빈 배열 — 디렉터리 부재는 오류가 아니다. */
267
+ function contentPageFiles(repoRoot) {
268
+ const dir = join(repoRoot, "content", "pages");
269
+ try {
270
+ return readdirSync(dir)
271
+ .filter((n) => n.endsWith(".json"))
272
+ .sort()
273
+ .map((n) => join(dir, n));
274
+ } catch {
275
+ return [];
276
+ }
277
+ }
278
+
279
+ const CONTENT_MODE = detectContentMode(root);
280
+
281
+ /**
282
+ * D1·D2 — **문서가 가리키는 좌표가 실물인가**(memo129 T-C · memo125 요건 5·6).
283
+ *
284
+ * codegen 은 레포 루트 `AGENTS.md` 를 **가장 먼저** 읽는다. 그 문서가 없는 파일을 가리키면 에이전트는
285
+ * 없는 것을 찾다가 제 좌표를 짜 버린다 — 2026-07-30 토큰 기준선 실측에서 실제로 났다(페이지 신설
286
+ * 지시에서 에이전트가 콘텐츠 계약 대신 라우트를 새로 짰다). 죽은 좌표는 문서 위생 문제가 아니라
287
+ * **토큰 원가**다. 사람 리뷰로는 두 번 놓쳤으므로(parse.ts 회수·`ui/` 축약 경로) 기계가 센다.
288
+ *
289
+ * 무엇을 검사 대상으로 삼는가 — **오탐이 나면 이 검사는 무력화된다**(µ주석이 붙어 조용히 꺼진다).
290
+ * 그래서 "명백히 이 레포의 파일 경로"인 백틱 토큰만 본다:
291
+ * ⑴ 백틱 안 · `/` 포함 · 확장자(ts/tsx/js/jsx/mjs/cjs/json/css/md)로 끝남
292
+ * ⑵ 자리표시자·글로브 문자 없음(`<slug>`·`**`·`{id}` 는 경로가 아니라 패턴이다 — 문자 집합으로 배제)
293
+ * ⑶ 다른 레포·설치물·생성물이 아님(`doc/` = 백엔드 정본 · `node_modules/` · `.zalkera/` = 팩 산출물 ·
294
+ * 절대경로 · `@scope/` 패키지 경로)
295
+ * 남는 것은 전부 레포 루트 기준 상대 경로여야 한다. **디렉터리는 안 센다** — `public/` 처럼 팩이
296
+ * 만들어 주는 자리가 있어 존재 판정이 참이 아니다.
297
+ */
298
+ const DOC_PATH_TOKEN = /^[A-Za-z0-9_.\-/[\]]+\.(?:tsx?|jsx?|mjs|cjs|json|css|md)$/;
299
+ const DOC_PATH_SKIP_PREFIX = ["doc/", "node_modules/", ".zalkera/", "@", "/", "http"];
300
+
301
+ /** 문서 본문에서 이 레포의 파일을 가리키는 것으로 판정되는 백틱 토큰. */
302
+ function docPathTokens(text, only) {
303
+ const seen = new Set();
304
+ for (const m of text.matchAll(/`([^`\n]+)`/g)) {
305
+ const tok = m[1];
306
+ if (!tok.includes("/")) continue;
307
+ if (!DOC_PATH_TOKEN.test(tok)) continue;
308
+ if (DOC_PATH_SKIP_PREFIX.some((p) => tok.startsWith(p))) continue;
309
+ if (only && !only.test(tok)) continue;
310
+ seen.add(tok);
311
+ }
312
+ return [...seen].sort();
313
+ }
314
+
315
+ function fileExists(path) {
316
+ try {
317
+ return statSync(path).isFile();
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * D 규칙의 심각도 — 좌표의 실재는 **사실 판정**이라 어느 레포에서도 참이지만, 남의 레포 문서에
325
+ * 에러를 던지지는 않는다(전제 1 "강제할 수 없다"). 우리 계약을 하나라도 선언한 레포에서만 error 다.
326
+ */
327
+ function docsSink() {
328
+ return STYLE_MODE === "declared" || CONTENT_MODE === "declared" ? errors : warnings;
329
+ }
330
+
331
+ /**
332
+ * D1 — 레포 루트 `AGENTS.md` 의 좌표 실재.
333
+ * D2 — 설치된 `@zalkera/client` 의 `llms.txt` 가 지목한 **본보기 좌표**의 실재. 이 레포가 그 본보기라
334
+ * (`@zalkera/storefront-examples`) 여기서만 돈다 — 남의 레포에 우리 레시피의 경로를 들이대면
335
+ * 전부 오탐이다. `src/app|components|lib/` 로 좁히는 것은 llms.txt 가 client 자신의 소스
336
+ * (`sections.ts` 등)도 언급하기 때문이고, 그 셋이 템플릿 형상의 서브트리다.
337
+ */
338
+ function checkDocCoordinates() {
339
+ const repoRoot = resolve(root, "..");
340
+ const sink = docsSink();
341
+
342
+ const agents = join(repoRoot, "AGENTS.md");
343
+ if (fileExists(agents)) {
344
+ for (const tok of docPathTokens(readFileSync(agents, "utf8"))) {
345
+ if (!fileExists(join(repoRoot, tok))) {
346
+ sink.push(
347
+ `[D1] AGENTS.md 가 없는 파일을 가리킵니다: \`${tok}\` — codegen 이 이 문서를 가장 먼저 ` +
348
+ `읽습니다. 실물 경로로 고치거나, 이제 없는 파일이면 경로 표기를 지우세요.`,
349
+ );
350
+ }
351
+ }
352
+ }
353
+
354
+ // D2 — 본보기 레포 전용.
355
+ let isExemplar = false;
356
+ try {
357
+ isExemplar = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).name === "@zalkera/storefront-examples";
358
+ } catch {
359
+ isExemplar = false;
360
+ }
361
+ if (!isExemplar) return;
362
+
363
+ const llms = join(repoRoot, "node_modules", "@zalkera", "client", "llms.txt");
364
+ if (!fileExists(llms)) return; // 미설치 — 없는 것을 센 척하지 않는다.
365
+ for (const tok of docPathTokens(readFileSync(llms, "utf8"), /^src\/(app|components|lib)\//)) {
366
+ if (!fileExists(join(repoRoot, tok))) {
367
+ sink.push(
368
+ `[D2] llms.txt 가 본보기로 지목한 \`${tok}\` 이 이 레포에 없습니다 — 레시피가 실물을 ` +
369
+ `앞질렀거나(공표 먼저) 본보기가 그 파일을 지웠습니다. 어느 쪽인지 확인해 한쪽을 맞추세요.`,
370
+ );
371
+ }
372
+ }
373
+ }
374
+
375
+ /** N 규칙 위반을 담을 배열 — declared 면 errors, inferred 면 warnings, none 이면 null(스킵). */
376
+ function contentSink() {
377
+ if (CONTENT_MODE === "none") return null;
378
+ return CONTENT_MODE === "declared" ? errors : warnings;
379
+ }
380
+
381
+ /**
382
+ * S 규칙 위반을 담을 배열을 모드별 심각도로 고른다. null 이면 스킵(none 모드).
383
+ * declared → S1~S4 는 errors, S5 는 warnings / inferred → 전부 warnings / none → null(스킵)
384
+ */
385
+ function styleSink(rule) {
386
+ if (STYLE_MODE === "none") return null;
387
+ if (STYLE_MODE === "declared") return rule === "S5" ? warnings : errors;
388
+ return warnings; // inferred — 전부 경고
389
+ }
390
+
391
+ /**
392
+ * ISR-우선 게이트 대상에서 빼는 라우트 세그먼트(app 하위 디렉터리명).
393
+ * 이들은 SEO 페이지가 아니라 **정당하게 동적/세션/쓰기**인 인터랙티브 경로다:
394
+ * - api : BFF route handler(ACTION 서버 함수·페이지 아님·revalidate 엔드포인트 포함)
395
+ * - cart/checkout/mypage/account : 세션·쓰기 인터랙티브
396
+ * - login/auth : 인증 플로우
397
+ * - orders : 주문 조회(토큰·연락처로 식별하는 세션성 조회)
398
+ * 그 외 app 하위의 page.* = 공개 SEO 라우트(홈·목록·상세·콘텐츠)로 간주해 ISR/static 을 강제한다.
399
+ */
400
+ const SEO_EXCLUDE_SEGMENTS = new Set(["api", "cart", "checkout", "mypage", "account", "login", "auth", "orders"]);
401
+
402
+ // 세그먼트 정규화: 라우트 그룹 `(group)`·동적 `[slug]` 껍데기를 벗겨 이름만 비교한다.
403
+ function normalizeSegment(seg) {
404
+ return seg.replace(/^\((.*)\)$/, "$1").replace(/^\[+\.*(.*?)\]+$/, "$1");
405
+ }
406
+
407
+ // app/ 하위의 page 파일인지 + SEO(비제외) 라우트인지 판정.
408
+ function isSeoPageFile(file) {
409
+ if (!/^page\.(ts|tsx|js|jsx)$/.test(basename(file))) return false;
410
+ const relFromRoot = relative(root, file);
411
+ const segments = relFromRoot.split(sep);
412
+ if (segments[0] !== "app") return false; // src/app 아래만
413
+ // app 과 파일명(page.*) 사이의 디렉터리 세그먼트만 검사.
414
+ const dirSegments = segments.slice(1, -1).map(normalizeSegment);
415
+ return !dirSegments.some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
416
+ }
417
+
418
+ // SEO page 에서 금지되는 per-page SSR 유발 패턴들.
419
+ const SSR_FORBIDDEN = [
420
+ {re: /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/, why: `export const dynamic = "force-dynamic" (전 요청 SSR 강제)`},
421
+ {re: /export\s+const\s+revalidate\s*=\s*0\b/, why: `export const revalidate = 0 (ISR 무력화·매 요청 재생성)`},
422
+ {re: /\bcookies\s*\(/, why: `page 레벨 cookies() 호출 (동적 렌더 opt-in — 세션값은 클라이언트 컴포넌트로)`},
423
+ {re: /\bheaders\s*\(/, why: `page 레벨 headers() 호출 (동적 렌더 opt-in)`},
424
+ {re: /cache\s*:\s*["']no-store["']/, why: `fetch(..., { cache: "no-store" }) (캐시 불가·매 요청 fetch)`},
425
+ {re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: `fetch(..., { next: { revalidate: 0 } }) (ISR 무력화)`},
426
+ ];
427
+
428
+ function walk(dir) {
429
+ for (const name of readdirSync(dir)) {
430
+ if (name === "node_modules" || name === ".next") continue;
431
+ const full = join(dir, name);
432
+ if (statSync(full).isDirectory()) {
433
+ walk(full);
434
+ } else if (/\.(ts|tsx|js|jsx)$/.test(name)) {
435
+ check(full);
436
+ if (isLayoutFile(full)) layoutFiles.push(full);
437
+ } else if (name.endsWith(".css")) {
438
+ cssFiles.push(full);
439
+ }
440
+ }
441
+ }
442
+
443
+ // ── C1b: layout 폭발반경 게이트 ──────────────────────────────────
444
+ //
445
+ // 왜 파일 하나가 아니라 그래프인가: 실제로 터졌던 사고가 그 모양이었다. `cookies()` 는 layout 안이
446
+ // 아니라 layout 이 import 한 `SiteHeader`(당시 async RSC) 안에 있었고, 그게 **전 라우트**를 요청마다
447
+ // 동적 렌더로 끌고 갔다(SiteHeader.tsx 주석에 기록). layout 파일만 훑는 검사는 그 사고를 못 잡는다 —
448
+ // 안심만 주는 게이트는 없느니만 못하다.
449
+ //
450
+ // 폭발반경이 C1 과 다르다: page 의 동적화는 그 라우트 1개, layout 의 동적화는 그 서브트리 전부다.
451
+
452
+ /**
453
+ * layout/template 파일인가(app 하위). 라우트 그룹 `(shop)`·병렬 라우트 `@slot` 어디에 있든 잡는다.
454
+ *
455
+ * C1 의 SEO 제외 세그먼트(cart·mypage·login…)는 layout 에도 적용한다. `app/mypage/layout.tsx` 의
456
+ * 인증 게이트(`cookies()` + redirect)는 표준 Next 패턴이고, 그 폭발반경은 **어차피 동적인**
457
+ * mypage 서브트리뿐이다 — 이 게이트 자신의 논리로도 잡을 이유가 없다.
458
+ */
459
+ function isLayoutFile(file) {
460
+ if (!/^(layout|template)\.(ts|tsx|js|jsx)$/.test(basename(file))) return false;
461
+ const segments = relative(root, file).split(sep);
462
+ if (segments[0] !== "app") return false;
463
+ return !segments.slice(1, -1).map(normalizeSegment).some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
464
+ }
465
+
466
+ /**
467
+ * 서버 렌더를 요청마다 강제하는 어휘. Next 의 동적 opt-in 은 열거적이다 — 태그만 실은 fetch 는
468
+ * 여기 없다(그래서 layout 의 `getSiteConfig({tags})` 는 정적성을 깨지 않는다).
469
+ *
470
+ * `corpus` 가 규칙마다 다른 이유 — **이게 없으면 규칙이 조용히 죽는다**:
471
+ * - "code": 문자열 리터럴을 지운 사본. 호출형(`cookies()`) 검출용. 문자열을 지워야
472
+ * `` throw new Error(`cookies() 를 못 읽었습니다`) `` 같은 메시지가 오탐이 안 된다.
473
+ * - "text": 주석만 지운 사본. **값이 문자열 안에 있는** 규칙용(`"force-dynamic"`·`"no-store"`).
474
+ * 이들을 "code" 에서 돌리면 정규식이 찾는 그 문자열이 검출 전에 `""` 로 소거돼
475
+ * **영원히 매치되지 않는다**(실제로 그렇게 만들었다가 검수에서 잡혔다 — import 추출에서 밟은
476
+ * 것과 같은 함정을 검출 쪽에 남겨뒀었다). 이 규칙들은 `export const dynamic =`·`cache:` 같은
477
+ * 앞머리를 요구하므로 단순 메시지 문자열엔 걸리지 않는다.
478
+ */
479
+ const DYNAMIC_API = [
480
+ {re: /\bcookies\s*\(/, why: "cookies()", corpus: "code"},
481
+ {re: /\bheaders\s*\(/, why: "headers()", corpus: "code"},
482
+ {re: /\bdraftMode\s*\(/, why: "draftMode()", corpus: "code"},
483
+ {re: /\bconnection\s*\(/, why: "connection()", corpus: "code"},
484
+ {re: /\b(?:unstable_noStore|noStore)\s*\(/, why: "unstable_noStore()", corpus: "code"},
485
+ {re: /export\s+const\s+revalidate\s*=\s*0\b/, why: "export const revalidate = 0", corpus: "code"},
486
+ {re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: "fetch(..., {next: {revalidate: 0}})", corpus: "code"},
487
+ {re: /cache\s*:\s*["']no-store["']/, why: `fetch(..., {cache: "no-store"})`, corpus: "text"},
488
+ {
489
+ re: /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/,
490
+ why: `export const dynamic = "force-dynamic"`,
491
+ corpus: "text",
492
+ },
493
+ ];
494
+
495
+ /**
496
+ * 주석을 지운 사본. 안 지우면 "예전엔 cookies() 를 읽었는데" 같은 **설명 주석**이 오탐이 된다
497
+ * (SiteHeader 주석이 정확히 그렇다). `//` 는 URL(`http://`)과 구분하려고 앞 문자가 `:` 이 아닐 때만.
498
+ */
499
+ function stripComments(src) {
500
+ return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
501
+ }
502
+
503
+ /**
504
+ * 주석에 더해 **문자열 리터럴까지** 지운 사본 — 호출형 규칙(`corpus: "code"`) 검출용.
505
+ *
506
+ * 문자열을 지우는 이유: `` throw new Error(`cookies() 를 못 읽었습니다`) `` 같은 메시지가 오탐이 된다.
507
+ *
508
+ * ⚠️ 두 가지를 조심해야 한다(둘 다 실제로 밟았다):
509
+ * 1. 이 사본에서 **import 지정자를 뽑으면 안 된다** — `from "@/lib/session"` 이 `from ""` 이 돼
510
+ * 그래프가 조용히 끊긴다. import 추출은 [stripComments] 사본에서 한다.
511
+ * 2. 값이 문자열 안에 있는 규칙(`"force-dynamic"`)을 이 사본에서 찾으면 **영원히 못 찾는다**.
512
+ * 그래서 DYNAMIC_API 에 `corpus` 가 있다.
513
+ *
514
+ * `'`·`"` 는 **개행을 넘지 못하게** 한다. 안 그러면 JSX 의 `Don't` 아포스트로피가 아래쪽 `It's` 와
515
+ * 짝지어져 그 사이의 실코드(`cookies()` 포함)를 통째로 삼키고, 문자열 속 `//`(예: `"//cdn.example.com"`)
516
+ * 가 닫는 따옴표를 주석으로 먹혀 고아 따옴표가 다음 줄까지 삼킨다. 삼켜진 자리는 검출이 안 된다 —
517
+ * 조용한 거짓 음성이다. 자바스크립트 문자열은 어차피 개행을 못 넘으므로(템플릿 리터럴만 넘는다)
518
+ * 이 제약은 정확하기도 하다.
519
+ */
520
+ function stripCommentsAndStrings(src) {
521
+ return stripComments(src)
522
+ .replace(/`(?:\\[\s\S]|[^\\`])*`/g, "``")
523
+ .replace(/"(?:\\.|[^\\"\n])*"/g, '""')
524
+ .replace(/'(?:\\.|[^\\'\n])*'/g, "''");
525
+ }
526
+
527
+ /** `@/x`·상대경로만 해석한다. 외부 패키지(next·react·@zalkera/client)는 추적 대상이 아니다. */
528
+ function resolveImport(spec, fromFile) {
529
+ let base;
530
+ if (spec.startsWith("@/")) base = join(root, spec.slice(2));
531
+ else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
532
+ else return null;
533
+
534
+ for (const cand of [base, ...[".ts", ".tsx", ".js", ".jsx"].flatMap((e) => [base + e, join(base, "index" + e)])]) {
535
+ try {
536
+ if (statSync(cand).isFile()) return cand;
537
+ } catch {
538
+ /* 다음 후보 */
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+
544
+ /**
545
+ * 정적 import·re-export 의 모듈 지정자. 동적 `import()`·side-effect import 는 추적하지 않는다
546
+ * (아래 한계 주석).
547
+ *
548
+ * 두 가지로 가짜 간선을 막는다:
549
+ * - **행 머리 앵커**(`^\s*`): 진짜 import 는 문장 위치에 온다. `const SNIPPET = 'import {x} from
550
+ * "@/lib/session"'` 처럼 문자열 안에 든 코드 조각은 행 중간이라 안 걸린다.
551
+ * - **템플릿 리터럴 제거**: codegen 제품이라 코드-as-문자열이 백틱 안에 살고, 거기 든 import 는
552
+ * 행 머리에 올 수 있다.
553
+ *
554
+ * `import type`/`export type` 도 **간선이 아니다** — 컴파일 시 소거돼 런타임에 그 모듈을 물지 않는다.
555
+ * 빼지 않으면 layout 이 `import type {SessionInfo} from "@/lib/session"` 만 해도 session.ts 의
556
+ * `cookies()` 가 잡히는 순수 오탐이 난다.
557
+ */
558
+ function importSpecifiers(src) {
559
+ const noTemplates = src.replace(/`(?:\\[\s\S]|[^\\`])*`/g, "``");
560
+ const out = [];
561
+ for (const re of [
562
+ /^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']/gm,
563
+ /^\s*export\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']/gm,
564
+ ]) {
565
+ for (const m of noTemplates.matchAll(re)) out.push(m[1]);
566
+ }
567
+ return out;
568
+ }
569
+
570
+ /**
571
+ * `"use client"` 지시자를 가진 파일인가 — 즉 서버 그래프의 **경계**인가.
572
+ *
573
+ * 파일 **선두**에서만 판정한다(주석·BOM 은 건너뛴다). raw 전체에 `/^…/m` 을 걸면:
574
+ * - 템플릿 리터럴 안 행머리의 `"use client"` 가 파일 전체를 클라이언트로 오판 → 그 파일의
575
+ * `cookies()` 를 못 본다. codegen 제품이라 **템플릿 문자열이 현실적**이다.
576
+ * - BOM 이 앞서면 반대로 진짜 지시자를 못 봐서, 클라이언트 컴포넌트 체인을 서버로 오판한다.
577
+ * 지시자는 어차피 선두에만 유효하므로 선두 판정이 정확하기도 하다.
578
+ */
579
+ function isClientBoundary(raw) {
580
+ return /^["']use client["']/.test(stripComments(raw).replace(/^/, "").trimStart());
581
+ }
582
+
583
+ /**
584
+ * layout 에서 시작해 서버 모듈만 따라간다. `"use client"` 파일은 **경계라서 멈춘다** — 그 아래는
585
+ * 브라우저에서 돌고 서버 렌더를 동적으로 만들지 않는다(사고의 처방이 정확히 이 경계였다).
586
+ * 반환: [{file, why, chain}] — chain 은 layout→…→범인 경로.
587
+ */
588
+ function dynamicApiReachableFrom(entry) {
589
+ const found = [];
590
+ const seen = new Set();
591
+ const queue = [{file: entry, chain: [entry]}];
592
+
593
+ while (queue.length > 0) {
594
+ const {file, chain} = queue.shift();
595
+ if (seen.has(file)) continue;
596
+ seen.add(file);
597
+
598
+ let raw;
599
+ try {
600
+ raw = readFileSync(file, "utf8");
601
+ } catch {
602
+ continue;
603
+ }
604
+ if (isClientBoundary(raw)) continue; // 클라이언트 경계 — 여기서 끊는다.
605
+
606
+ const text = stripComments(raw); // 주석만 제거(문자열 값·import 지정자 보존)
607
+ const code = stripCommentsAndStrings(text); // 문자열까지 제거(호출형 검출)
608
+ for (const {re, why, corpus} of DYNAMIC_API) {
609
+ if (re.test(corpus === "text" ? text : code)) found.push({file, why, chain});
610
+ }
611
+ // import 는 문자열을 남긴 사본에서 — 지운 사본에서 뽑으면 지정자가 사라져 그래프가 끊긴다.
612
+ for (const spec of importSpecifiers(text)) {
613
+ const next = resolveImport(spec, file);
614
+ if (next && !seen.has(next)) queue.push({file: next, chain: [...chain, next]});
615
+ }
616
+ }
617
+ return found;
618
+ }
619
+
620
+ /** 위반마다 맞는 처방. 고정 문구를 쓰면 no-store 위반에 "세션 판정을 내려라"라고 답하게 된다. */
621
+ function remedyFor(why) {
622
+ if (/cookies|headers|draftMode|connection/.test(why)) {
623
+ return `세션·요청 의존 값은 클라이언트 컴포넌트(아일랜드)로 내려라 — SiteHeader + useAuthHint 가 그 선례다`;
624
+ }
625
+ return `layout 은 정적으로 두고, 신선도가 필요하면 태그 fetch(\`{tags}\`) + 온디맨드 revalidate 를 써라`;
626
+ }
627
+
628
+ function checkLayoutBlastRadius() {
629
+ // 범인이 같으면 고칠 곳도 하나다 — 조상 layout 수만큼 반복 출력하지 않는다.
630
+ const reported = new Set();
631
+ for (const layout of layoutFiles) {
632
+ for (const {file, why, chain} of dynamicApiReachableFrom(layout)) {
633
+ const key = `${file}|${why}`;
634
+ if (reported.has(key)) continue;
635
+ reported.add(key);
636
+
637
+ // 면제는 **범인 파일**에 붙인다 — layout 에 붙이면 그 아래 전부가 한 번에 뚫린다.
638
+ // 신 마커 zalkera- + 구 마커(oneq-/oneque-)를 양형 수용한다(리네임 이행기).
639
+ const allow = readFileSync(file, "utf8").match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
640
+ const path = chain.map((f) => relative(process.cwd(), f)).join(" → ");
641
+ const detail =
642
+ `${relative(process.cwd(), layout)} 이 ${why} 에 도달한다 → ${path}. ` +
643
+ `layout 의 동적 API 는 **그 아래 전 라우트**를 요청마다 SSR 로 만든다`;
644
+ if (allow) {
645
+ warnings.push(`[C1b] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
646
+ } else {
647
+ servingSink().push(
648
+ `[C1b] ${detail}(memo31 §0-1). ${remedyFor(why)}. 꼭 필요하면 ` +
649
+ `${relative(process.cwd(), file)} 에 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하라 ` +
650
+ `(마커는 layout 이 아니라 **이 파일**에 붙어야 듣는다).`,
651
+ );
652
+ }
653
+ }
654
+ }
655
+ }
656
+
657
+ /**
658
+ * C2 — 섹션 렌더러 커버리지. vendored `@zalkera/client` 의 SECTION_CONTRACT 와 SectionRenderer 의 case 를
659
+ * 대조한다. 어휘 사본이 넷이라 사람 주석 규약으로는 갈라짐을 못 막는다는 게 실측된 교훈이라(memo102 §6),
660
+ * 레포 안에서 확인 가능한 짝은 기계가 센다. 계약을 못 읽으면(구 client·미설치) **검사를 건너뛴다** —
661
+ * BYO 레포에서 이 검사가 빌드를 막으면 안 되기 때문이다.
662
+ */
663
+ /**
664
+ * S8 — 표현 계약(L1) 배선 검사(memo109). **declared 전용**이다: S8 은 위생이 아니라 **선언의 이행 검사**라,
665
+ * 계약을 자처하지 않은 레포에는 검사할 약속 자체가 없다(inferred·none 스킵 — S1~S5 와 게이팅이 다른 이유).
666
+ *
667
+ * 두 조각을 센다:
668
+ * - **S8-a** globals.css 의 `@theme` + `--color-primary` — 없으면 `bg-primary` 유틸리티 자체가 생성되지 않는다.
669
+ * 4키 전수·knob 검사는 하지 않는다(정당한 변형에 오탐한다 — memo109 §2).
670
+ * - **S8-b** root layout 의 `parseThemeColors(` **호출** + `<html>` 의 `style` — 이 주입이 L1 의 심장이다.
671
+ * **문자열이 아니라 호출을 앵커로 삼는다**: layout 주석이 `themeColors` 를 담고 있어(실측) 문자열 검사는
672
+ * 배선을 지우고 주석만 남긴 소스를 통과시킨다. import 원천(로컬 `lib/theme`·`@zalkera/client`)은 묻지 않는다.
673
+ *
674
+ * **한계 정직**: 존재 검사지 동작 검사가 아니다 — 호출하고 결과를 안 쓰거나 빈 객체를 실으면 통과한다.
675
+ * "배선을 지웠다"는 잡고 "배선이 고장났다"는 못 잡는다. 나머지 반쪽은 등재 전 스모크 개시(memo107 §5.2 실효층)다.
676
+ */
677
+ function checkThemeWiring() {
678
+ if (STYLE_MODE !== "declared") return; // 선언 없는 레포의 주입 부재는 결함이 아니라 정상이다.
679
+
680
+ const globalsCss = join(root, "app", "globals.css");
681
+ let css = null;
682
+ try {
683
+ css = readFileSync(globalsCss, "utf8");
684
+ } catch {
685
+ return; // 파일 부재는 S3 가 이미 error 로 잡는다 — 같은 사실을 두 번 외치지 않는다.
686
+ }
687
+
688
+ // S8-a — 토큰 정의.
689
+ if (!/@theme\b/.test(css) || !/--color-primary\s*:/.test(css)) {
690
+ errors.push(
691
+ `[S8] ${relative(process.cwd(), globalsCss)}: @theme 토큰 정의(--color-primary)가 없습니다 — ` +
692
+ `bg-primary 유틸리티가 생성되지 않아 테넌트 색이 어디에도 안 실립니다.`,
693
+ );
694
+ }
695
+
696
+ // S8-b — 주입 배선.
697
+ const rootLayout = ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]
698
+ .map((n) => join(root, "app", n))
699
+ .find((p) => {
700
+ try {
701
+ return statSync(p).isFile();
702
+ } catch {
703
+ return false;
704
+ }
705
+ });
706
+ if (!rootLayout) return; // root layout 부재는 C1 계열의 몫.
707
+
708
+ const raw = readFileSync(rootLayout, "utf8");
709
+ const src = stripComments(raw); // 주석은 거짓말을 한다 — 앵커를 코드에서만 찾는다.
710
+ const injects = /parseThemeColors\s*\(/.test(src) && /<html[^>]*\sstyle=/.test(src);
711
+ if (injects) return;
712
+
713
+ // 탈출구 — 손으로 계약을 지키는 것도 정당하다(memo108 §1 "kit 은 자격 조건이 아니다"). 다른 이름의
714
+ // 자기 헬퍼로 배선한 레포를 error 로 막으면 기계가 정당한 자유를 벌한다. 마커면 warning 으로 강등하고
715
+ // 실효 확인은 스모크 개시(실효층)가 맡는다. 마커는 원문에서 찾는다(주석이 곧 마커다).
716
+ const sink = /zalkera-allow-custom-theme-inject/.test(raw) ? warnings : errors;
717
+ sink.push(
718
+ `[S8] ${relative(process.cwd(), rootLayout)}: 테마 주입 배선이 없습니다 — ` +
719
+ `parseThemeColors(...) 호출 + <html style={...}> 가 있어야 콘솔의 색 변경이 화면에 반영됩니다. ` +
720
+ `직접 배선했다면 "// zalkera-allow-custom-theme-inject: <이유>" 마커로 사유를 남기세요.`,
721
+ );
722
+ }
723
+
724
+ function checkSectionCoverage() {
725
+ let contract;
726
+ try {
727
+ const mod = createRequire(import.meta.url)("@zalkera/client");
728
+ contract = mod?.SECTION_CONTRACT;
729
+ } catch {
730
+ return; // client 미설치·구버전 — 스킵.
731
+ }
732
+ if (!Array.isArray(contract) || contract.length === 0) return;
733
+
734
+ const rendererPath = join(root, "components/sections/SectionRenderer.tsx");
735
+ let src;
736
+ try {
737
+ src = readFileSync(rendererPath, "utf8");
738
+ } catch {
739
+ return; // 렌더러가 없는 구조(BYO) — 검사 대상 아님.
740
+ }
741
+ const cases = new Set([...src.matchAll(/case\s+"([A-Z_]+)"/g)].map((m) => m[1]));
742
+ const missing = contract.map((c) => c.type).filter((t) => !cases.has(t));
743
+ if (missing.length > 0) {
744
+ servingSink().push(
745
+ `[C2] SectionRenderer 가 계약의 ${missing.join("·")} 를 안 그린다 — 미지 타입은 조용히 스킵되므로 ` +
746
+ `콘솔에서 넣어도 화면에 안 나온다. case 를 추가하거나, 의도적 미지원이면 그 사유를 커밋에 남겨라.`,
747
+ );
748
+ }
749
+ }
750
+
751
+ /*
752
+ * ── N1~N5 : 콘텐츠 파일 계약 ────────────────────────────────────────────────
753
+ *
754
+ * 이 규칙군이 잡는 것은 전부 **조용한 실패**다. 계약을 어긴 콘텐츠 파일은 예외를 던지지 않는다 —
755
+ * 렌더 가드가 그 값만 떨구고 페이지는 살아 있으므로(그게 계약이다), 화면에서 섹션 하나가 사라진
756
+ * 것을 사람이 눈으로 찾아야 한다. 여기서 죽이면 그 결함이 고객 화면이 아니라 우리 터미널에서 난다.
757
+ *
758
+ * 반대로 **잡지 않는 것**도 못박아 둔다: 상품 `handle` 이 카탈로그에 실재하는지는 소스만 봐서
759
+ * 알 수 없다(DB = 레인 B). 그 축의 잣대는 개시된 산출물이다 — 여기서 추측으로 error 를 내면
760
+ * 정상 레포가 막힌다.
761
+ */
762
+
763
+ /** 계약 정본(설치된 `@zalkera/client` 운반체). 못 읽으면 타입 대조만 건너뛴다 — BYO 레포를 막지 않는다. */
764
+ function sectionContractMap() {
765
+ try {
766
+ const contract = createRequire(import.meta.url)("@zalkera/client")?.SECTION_CONTRACT;
767
+ if (!Array.isArray(contract) || contract.length === 0) return null;
768
+ return new Map(contract.map((c) => [c.type, c]));
769
+ } catch {
770
+ return null;
771
+ }
772
+ }
773
+
774
+ /**
775
+ * 계약을 못 읽으면 **N4·N5 가 통째로 조용히 스킵된다** — 미지 섹션 타입도, 빠진 필수 참조도 무검출인
776
+ * 채 `✅ 통과` 가 찍힌다. 실측으로 밟았다(node_modules 심링크가 깨진 트리에서 전부 통과).
777
+ *
778
+ * 잴 것이 없는 것은 통과가 아니라 **판정 불가**다. `npm ci` 를 안 한 트리에서 검사기를 돌리는 것은
779
+ * 사용자 실수이지 합격 조건이 아니므로, 조용히 넘기지 않고 경고로 드러낸다(스킵 자체는 유지 —
780
+ * 계약 없이 판정할 방법이 없고, error 로 막으면 설치 전 훑어보기가 불가능해진다).
781
+ */
782
+ function warnIfContractMissing(contract) {
783
+ if (contract) return;
784
+ warnings.push(
785
+ "[W-CONTRACT] @zalkera/client 를 못 읽어 **섹션 계약 검사(N4·N5)를 건너뛰었습니다** — " +
786
+ "미지 섹션·빠진 필수 참조가 무검출입니다. `npm ci` 후 다시 돌리십시오.",
787
+ );
788
+ }
789
+
790
+ /** 참조 방언 키 판정 — 백엔드 `SeedAssetReferences`·팩 게이트와 **같은 판정**이어야 한다(계약 rev 4 `dialects`). */
791
+ const isAssetRefKey = (key) => key === "asset" || (key.length > 5 && key.endsWith("Asset"));
792
+ const isProductRefKey = (key) => key === "product" || (key.length > 7 && key.endsWith("Product"));
793
+ const isProductsRefKey = (key) => key === "products" || (key.length > 8 && key.endsWith("Products"));
794
+ /** 재작성된 뒤의 id 형 키 — 소스에는 있으면 안 된다(테넌트 스코프 값). */
795
+ const isIdFormKey = (key) =>
796
+ (key.endsWith("Id") && (isAssetRefKey(key.slice(0, -2)) || isProductRefKey(key.slice(0, -2)))) ||
797
+ (key.endsWith("Ids") && isProductsRefKey(`${key.slice(0, -3)}s`));
798
+ /** 계약이 id 형으로 선언한 필수 참조(`productIds`)를 소스 방언 키(`products`)로 되돌린다. */
799
+ const sourceKeyOf = (idKey) => (idKey.endsWith("Ids") ? `${idKey.slice(0, -3)}s` : idKey.replace(/Id$/, ""));
800
+
801
+ /** 중첩까지 훑어 id 형 키의 경로를 모은다(`items[0].beforeAssetId` 처럼). */
802
+ function collectIdFormKeys(node, path = "", into = []) {
803
+ if (Array.isArray(node)) node.forEach((v, i) => collectIdFormKeys(v, `${path}[${i}]`, into));
804
+ else if (node && typeof node === "object") {
805
+ for (const [key, value] of Object.entries(node)) {
806
+ const here = path ? `${path}.${key}` : key;
807
+ if (isIdFormKey(key)) into.push(here);
808
+ else collectIdFormKeys(value, here, into);
809
+ }
810
+ }
811
+ return into;
812
+ }
813
+
814
+ /** 중첩까지 훑어 에셋 참조 값을 모은다. */
815
+ function collectAssetRefs(node, path = "", into = []) {
816
+ if (Array.isArray(node)) node.forEach((v, i) => collectAssetRefs(v, `${path}[${i}]`, into));
817
+ else if (node && typeof node === "object") {
818
+ for (const [key, value] of Object.entries(node)) {
819
+ const here = path ? `${path}.${key}` : key;
820
+ if (isAssetRefKey(key)) into.push({path: here, value});
821
+ else collectAssetRefs(value, here, into);
822
+ }
823
+ }
824
+ return into;
825
+ }
826
+
827
+ function checkContentContract() {
828
+ const sink = contentSink();
829
+ if (!sink) return;
830
+
831
+ const repoRoot = resolve(root, "..");
832
+ const rel = (p) => relative(process.cwd(), p);
833
+ const files = contentPageFiles(repoRoot);
834
+
835
+ // N1 — 매니페스트. 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다(계약 rev 4 `contentFile.manifest`).
836
+ const manifestPath = join(repoRoot, "content", "index.ts");
837
+ let manifest = null;
838
+ try {
839
+ manifest = readFileSync(manifestPath, "utf8");
840
+ } catch {
841
+ sink.push(
842
+ `[N1] ${rel(manifestPath)} 가 없습니다 — 콘텐츠 매니페스트(정적 import)가 없으면 ` +
843
+ `dev 에서 json 을 고쳐도 화면이 안 바뀌고(HMR 미발화), 빌드 산출물에 콘텐츠가 안 실립니다.`,
844
+ );
845
+ }
846
+
847
+ // N3 — 매니페스트 ↔ 파일. 파일만 있으면 그 페이지는 라우트도 sitemap 도 모르는 유령이 된다.
848
+ // **주석은 지운다** — 매니페스트의 사용법 예시가 주석 안에 import 문을 담고 있고, 그걸 실제
849
+ // import 로 읽으면 없는 파일을 찾는 오탐이 난다(실제로 이 검사를 넣자마자 그렇게 죽었다).
850
+ // 이 레포가 C1b·S8 에서 이미 밟은 함정과 같은 것이라 같은 처방을 쓴다.
851
+ const declaredSlugs = manifest
852
+ ? new Set(
853
+ [...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([\w-]+)\.json["']/g)].map((m) => m[1]),
854
+ )
855
+ : null;
856
+ if (declaredSlugs) {
857
+ for (const file of files) {
858
+ const slug = basename(file, ".json");
859
+ if (!declaredSlugs.has(slug)) {
860
+ sink.push(
861
+ `[N3] ${rel(file)} 를 ${rel(manifestPath)} 가 import 하지 않습니다 — ` +
862
+ `매니페스트에 없는 페이지는 라우트에도 sitemap 에도 없습니다(파일만 있고 아무도 못 봅니다).`,
863
+ );
864
+ }
865
+ }
866
+ for (const slug of declaredSlugs) {
867
+ if (!files.some((f) => basename(f, ".json") === slug)) {
868
+ sink.push(`[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`);
869
+ }
870
+ }
871
+ }
872
+
873
+ const contract = sectionContractMap();
874
+ warnIfContractMissing(contract);
875
+
876
+ for (const file of files) {
877
+ let page;
878
+ try {
879
+ page = JSON.parse(readFileSync(file, "utf8"));
880
+ } catch (e) {
881
+ sink.push(`[N2] ${rel(file)}: JSON 파싱 실패 — ${e.message}`);
882
+ continue;
883
+ }
884
+ if (page == null || typeof page !== "object" || Array.isArray(page)) {
885
+ sink.push(`[N2] ${rel(file)}: 최상위가 객체여야 합니다(현재 ${Array.isArray(page) ? "배열" : typeof page}).`);
886
+ continue;
887
+ }
888
+
889
+ // N4 — 섹션 형상.
890
+ const sections = page.sections;
891
+ if (sections !== undefined && !Array.isArray(sections)) {
892
+ sink.push(`[N4] ${rel(file)}: sections 는 배열이어야 합니다 — 배열 순서가 곧 화면 순서입니다.`);
893
+ continue;
894
+ }
895
+ for (const [i, section] of (Array.isArray(sections) ? sections : []).entries()) {
896
+ const at = `${rel(file)} sections[${i}]`;
897
+ if (section == null || typeof section !== "object" || Array.isArray(section)) {
898
+ sink.push(`[N4] ${at}: 섹션은 { "type": …, "config": { … } } 객체여야 합니다.`);
899
+ continue;
900
+ }
901
+ if (typeof section.type !== "string" || section.type.trim() === "") {
902
+ sink.push(`[N4] ${at}: type 은 비어 있지 않은 문자열이어야 합니다 — 렌더러가 이 섹션을 통째로 건너뜁니다.`);
903
+ continue;
904
+ }
905
+ if ("sortOrder" in section) {
906
+ sink.push(
907
+ `[N4] ${at}: sortOrder 는 콘텐츠 파일에 없는 키입니다 — **배열 순서가 순서**입니다(계약 rev 4). ` +
908
+ `남겨 두면 "순서를 바꿔"가 고쳐야 할 자리가 둘이 됩니다.`,
909
+ );
910
+ }
911
+ const config = section.config;
912
+ if (config !== undefined && (config == null || typeof config !== "object" || Array.isArray(config))) {
913
+ sink.push(`[N4] ${at}: config 는 **객체**여야 합니다(JSON 문자열이 아닙니다 — 그건 DB 방언입니다).`);
914
+ continue;
915
+ }
916
+ const spec = contract?.get(section.type);
917
+ if (contract && !spec) {
918
+ sink.push(
919
+ `[N4] ${at}: 계약에 없는 섹션 타입 "${section.type}" — 렌더러가 조용히 건너뜁니다(화면에 안 나옵니다). ` +
920
+ `아는 어휘는 @zalkera/client 의 SECTION_CONTRACT 에 있습니다.`,
921
+ );
922
+ }
923
+
924
+ const cfg = config ?? {};
925
+ // N5 — id 형 직기입 금지.
926
+ for (const path of collectIdFormKeys(cfg)) {
927
+ sink.push(
928
+ `[N5] ${at}: id 형 키 "${path}" — 소스는 참조형으로 씁니다(에셋 = public 루트 절대 경로 · 상품 = handle). ` +
929
+ `숫자 id 는 테넌트 스코프라 이 소스를 다른 곳에 올리는 순간 의미를 잃습니다.`,
930
+ );
931
+ }
932
+ // N5 — 에셋 경로 실재.
933
+ for (const {path, value} of collectAssetRefs(cfg)) {
934
+ if (typeof value !== "string") {
935
+ sink.push(`[N5] ${at}: "${path}" 는 public 루트 절대 경로 문자열이어야 합니다.`);
936
+ continue;
937
+ }
938
+ if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.split("/").includes("..")) {
939
+ sink.push(
940
+ `[N5] ${at}: "${path}" = ${JSON.stringify(value)} — 레포 public/ 루트 절대 경로만 그려집니다` +
941
+ `(원격 URL·상대 경로·경로 탈출은 렌더에서 통째로 떨어집니다).`,
942
+ );
943
+ continue;
944
+ }
945
+ try {
946
+ statSync(join(repoRoot, "public", value));
947
+ } catch {
948
+ sink.push(`[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`);
949
+ }
950
+ }
951
+ // N5 — 계약 필수 참조.
952
+ const isFilled = (idKey) => {
953
+ const value = cfg[sourceKeyOf(idKey)];
954
+ return Array.isArray(value) ? value.length > 0 : typeof value === "string" && value.trim() !== "";
955
+ };
956
+ for (const idKey of spec?.requiredRefs ?? []) {
957
+ if (!isFilled(idKey)) {
958
+ sink.push(
959
+ `[N5] ${at}: ${section.type} 이 필수 참조 "${sourceKeyOf(idKey)}" 를 안 가리킵니다 — ` +
960
+ `렌더러가 이 섹션을 통째로 건너뜁니다(계약 ${idKey} 필수).`,
961
+ );
962
+ }
963
+ }
964
+ // ⚠ **`requiredRefsAnyOf` 도 집행한다(심의 차단 3).** 계약 rev 5 가 `SERVICE_MENU`·`BOOKING_CTA` 의
965
+ // 필수 참조를 `requiredRefs`(이제 빈 배열)에서 이 키로 옮겼는데 이 검사기는 옛 키만 읽고 있었다 —
966
+ // **참조가 하나도 없는 섹션이 통과했다**(실측). N5 가 존재 이유로 삼는 바로 그 결함이 무검출이었다.
967
+ // 팩 게이트(`pack-preset.mjs`)는 anyOf 를 집행하고 있어 **어휘 사본 둘이 갈라진 상태**였다.
968
+ for (const group of spec?.requiredRefsAnyOf ?? []) {
969
+ if (!group.some(isFilled)) {
970
+ const names = group.map(sourceKeyOf).join(" 또는 ");
971
+ sink.push(
972
+ `[N5] ${at}: ${section.type} 이 ${names} 중 **하나도** 안 가리킵니다 — ` +
973
+ `렌더러가 이 섹션을 통째로 건너뜁니다(계약 requiredRefsAnyOf).`,
974
+ );
975
+ }
976
+ }
977
+ }
978
+ }
979
+ }
980
+
981
+ function check(file) {
982
+ const src = readFileSync(file, "utf8");
983
+ const rel = relative(process.cwd(), file);
984
+ // ⚠ **파일 머리만 본다(심의 차단 4).** 종전은 `/m` 플래그로 **원문 전체의 행머리**를 봤고,
985
+ // 행머리에 `"use client"` 를 담은 **템플릿 리터럴 한 줄**이 있으면 SEO 페이지가 클라이언트로
986
+ // 오판돼 `force-dynamic` 이 통과했다(실측). 이 레포는 codegen 산출물을 다루므로 코드-as-문자열이
987
+ // 현실적인 조건이다. 형제 함수 `isClientBoundary()` 는 이 함정을 이미 문서화하고 고쳐 뒀는데
988
+ // 이 자리만 남아 있었다 — 같은 관례를 두 곳이 다르게 구현한 것(오늘 고친 S2 와 같은 계열).
989
+ // 덤으로 주석을 지우고 재므로 "주석 속 `use client` 언급"에 오탐하지 않는다.
990
+ const isClient = isClientBoundary(src);
991
+
992
+ // 서버 클라이언트 싱글턴 존재 확인 — create{Zalkera,Oneque}Client 호출(구 심볼 수용).
993
+ if (/create(?:Zalkera|Oneque)Client\s*\(/.test(src)) {
994
+ singletonFound = true;
995
+ if (isClient) servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
996
+ }
997
+
998
+ if (isClient) {
999
+ // E1: 값 import(= import type 아님)로 @zalkera/client 를 들여옴(구 @oneque/client 도 잡는다).
1000
+ const valueImport = /^import\s+(?!type\s)[^;]*from\s+["']@(?:zalkera|oneque)\/client["']/m.test(src);
1001
+ if (valueImport) {
1002
+ servingSink().push(`[E1] ${rel}: "use client" 파일에서 @zalkera/client 를 값으로 import 한다 (타입은 \`import type\` 으로).`);
1003
+ }
1004
+ // E2: 서버 싱글턴(lib/zalkera) import(구 lib/oneque 도 잡는다).
1005
+ if (/from\s+["'][^"']*lib\/(?:zalkera|oneque)["']/.test(src)) {
1006
+ servingSink().push(`[E2] ${rel}: "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera)을 import 한다.`);
1007
+ }
1008
+ }
1009
+
1010
+ // C1: ISR-우선 게이트 — SEO 라우트 page 는 per-page SSR 을 강제할 수 없다.
1011
+ if (isSeoPageFile(file) && !isClient) {
1012
+ const hits = SSR_FORBIDDEN.filter(({re}) => re.test(src)).map(({why}) => why);
1013
+ if (hits.length > 0) {
1014
+ const allow = src.match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
1015
+ const detail = `${rel}: SEO 라우트가 동적SSR 을 유발한다 → ${hits.join("; ")}`;
1016
+ if (allow) {
1017
+ warnings.push(`[C1] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
1018
+ } else {
1019
+ servingSink().push(
1020
+ `[C1] ${detail}. SEO 페이지는 ISR(export const revalidate = N) 또는 static 이어야 한다. ` +
1021
+ `실시간·개인화 값은 클라이언트 컴포넌트(아일랜드)로, 상태 변경은 BFF route handler 로 옮겨라. ` +
1022
+ `동적 SSR 이 꼭 필요하면 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하라(memo31 §0-12).`,
1023
+ );
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ // ── S 규칙: 스타일 규약 ──────────────────────────────────
1029
+ // stripComments 사본에서 검사한다(style={{·var(--oneq- 는 코드 토큰 — 문자열 값은 남겨야 잡힌다.
1030
+ // 문자열까지 지운 사본에서 찾으면 className 안의 값이 소거돼 영원히 못 잡는다).
1031
+ const text = stripComments(src);
1032
+
1033
+ // S6: 남의 토큰 어휘(shadcn 기본 변수명). 재작성 표(memo102 §4.1)의 좌변이 그대로 남아 있으면 잡는다.
1034
+ // 우리에겐 정의가 없는 토큰이라 Tailwind 가 클래스를 만들지 않고 → 색이 조용히 빠진 채 배포된다.
1035
+ const s6 = styleSink("S6");
1036
+ if (s6) {
1037
+ const alien = FOREIGN_TOKEN_CLASSES.filter((t) => new RegExp(`(^|[\\s"'\`])${t}(?![\\w-])`).test(text));
1038
+ if (alien.length > 0) {
1039
+ s6.push(
1040
+ `[S6] ${rel}: 남의 토큰 어휘 ${alien.join("·")} — 우리 @theme 에 없는 이름이라 색이 빠진다. ` +
1041
+ `재작성 표로 옮겨라(memo102 §4.1: bg-card→bg-surface, text-muted-foreground→text-muted 등).`,
1042
+ );
1043
+ }
1044
+ }
1045
+
1046
+ // S1: 죽은 레거시 토큰. globals.css @theme 로 부활한 유틸리티로 대체해야 한다.
1047
+ const s1 = styleSink("S1");
1048
+ if (s1 && /\.tsx$/.test(file) && /var\(--oneq-/.test(text)) {
1049
+ s1.push(
1050
+ `[S1] ${rel}: 죽은 레거시 토큰 var(--oneq-*) 참조. ` +
1051
+ `--oneq-primary → text-primary/bg-primary, --oneq-bg → text-primary-foreground 유틸리티로 대체하라(globals.css @theme).`,
1052
+ );
1053
+ }
1054
+
1055
+ // S2: JSX 인라인 style. CSS 변수 주입(style={{"--)은 정당 용례라 면제. 마커로도 억제 가능(모든 모드).
1056
+ const s2 = styleSink("S2");
1057
+ // ⚠ 마커는 **원문(`src`)에서 찾는다.** `text` 는 `stripComments(src)` 라 `//` 주석 마커가
1058
+ // 원리적으로 매치될 수 없었다 — 이 규칙이 안내하는 탈출구가 **한 번도 작동한 적이 없다**
1059
+ // (2026-08-01 첫 사용자가 발견). 형제 규칙 `allow-dynamic`(§S1)은 `readFileSync` 원문을 보므로
1060
+ // 처음부터 옳았다 — 같은 관례를 두 곳이 다르게 구현하고 있었다.
1061
+ if (s2 && /style=\{\{(?!\s*["'`]--)/.test(text) && !/\/\/\s*(?:zalkera|oneque)-allow-inline-style:/.test(src)) {
1062
+ s2.push(
1063
+ `[S2] ${rel}: JSX 인라인 style={{…}} 사용 — 스타일은 Tailwind 유틸리티 클래스로 표현하라. ` +
1064
+ `정당한 동적 스타일이면 \`// zalkera-allow-inline-style: <이유>\` 마커로 억제하라.`,
1065
+ );
1066
+ }
1067
+
1068
+ // S4: className 색 하드코딩(임의값). 테넌트 색은 primary 토큰 경유가 규약이다.
1069
+ const s4 = styleSink("S4");
1070
+ if (s4 && /(?:bg|text|border)-\[#/.test(text)) {
1071
+ s4.push(
1072
+ `[S4] ${rel}: className 색 임의값(bg-[#…]·text-[#…]·border-[#…]) — 브랜드색 하드코딩은 ` +
1073
+ `콘솔의 '말로 색 바꾸기'를 무력화한다. bg-primary 등 토큰을, 중립은 slate 스케일을 쓰라.`,
1074
+ );
1075
+ }
1076
+ }
1077
+
1078
+ /**
1079
+ * S3·S5 — Tailwind 배선(단일 CSS)의 존재·정합. codegen 이 배선을 지우거나 CSS 파일을 난립시키는
1080
+ * 회귀를 막는다. 파일 존재 + import 문자열 검사면 충분하다(§5.1).
1081
+ */
1082
+ function checkStyleWiring() {
1083
+ const globalsCss = join(root, "app", "globals.css");
1084
+
1085
+ // S3: Tailwind 배선(globals.css 존재 + root layout import). none 모드는 스킵.
1086
+ const s3 = styleSink("S3");
1087
+ if (s3) {
1088
+ let globalsOk = false;
1089
+ try {
1090
+ globalsOk = statSync(globalsCss).isFile();
1091
+ } catch {
1092
+ globalsOk = false;
1093
+ }
1094
+
1095
+ if (!globalsOk) {
1096
+ s3.push(
1097
+ `[S3] ${relative(process.cwd(), globalsCss)} 가 없습니다 — ` +
1098
+ `Tailwind 배선(@import "tailwindcss" + @theme 토큰)이 사라졌습니다. globals.css 를 복구하세요.`,
1099
+ );
1100
+ } else {
1101
+ // root layout(app/layout.*)이 globals.css 를 import 하는지 — 안 하면 스타일이 전혀 안 실린다.
1102
+ const rootLayout = ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]
1103
+ .map((n) => join(root, "app", n))
1104
+ .find((p) => {
1105
+ try {
1106
+ return statSync(p).isFile();
1107
+ } catch {
1108
+ return false;
1109
+ }
1110
+ });
1111
+ if (rootLayout) {
1112
+ const src = stripComments(readFileSync(rootLayout, "utf8"));
1113
+ if (!/import\s+["'][^"']*globals\.css["']/.test(src)) {
1114
+ s3.push(
1115
+ `[S3] ${relative(process.cwd(), rootLayout)} 이 globals.css 를 import 하지 않습니다 ` +
1116
+ `(\`import "./globals.css"\`). 배선이 없으면 Tailwind CSS·테마 토큰이 로드되지 않습니다.`,
1117
+ );
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ // S5: globals.css 외의 CSS 파일 난립 방지(단일 CSS 원칙). none 모드는 스킵.
1124
+ const s5 = styleSink("S5");
1125
+ if (s5) {
1126
+ const globalsAbs = resolve(globalsCss);
1127
+ for (const css of cssFiles) {
1128
+ if (resolve(css) !== globalsAbs) {
1129
+ s5.push(
1130
+ `[S5] ${relative(process.cwd(), css)} — src/app/globals.css 외의 CSS 파일. ` +
1131
+ `단일 CSS 원칙: 스타일은 Tailwind 유틸리티 클래스로, 색·폰트는 globals.css @theme 토큰으로.`,
1132
+ );
1133
+ }
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * 주석과 문자열 리터럴을 **공백으로 치환**한다(길이·줄바꿈 보존 → 인덱스가 원문과 일치).
1140
+ *
1141
+ * ⚠ 이게 없으면 **주석 한 줄이 코드 행세를 한다.** 실측으로 확인된 우회 셋:
1142
+ * ⒜ 가드를 주석 처리하고 그 아래에서 쿠키를 쓰기 ⒝ 관용구를 문자열 리터럴에 넣어 두기
1143
+ * ⒞ 호출되지 않는 중첩 함수 안에 가드를 두기. 셋 다 "가드가 0인데 통과"였다.
1144
+ * 정규식으로 소스를 재는 검사기는 **먼저 리터럴을 지우고** 재야 한다.
1145
+ *
1146
+ * ⚠ **정규식 리터럴을 반드시 함께 처리해야 한다**(초판이 빠뜨려 차단이 됐다). `const RE = /["']/;`
1147
+ * 한 줄이면 그 안의 따옴표가 문자열 시작으로 오인돼 **뒤가 통째로 공백**이 되고, 아래에 있던
1148
+ * `export async function POST` 이 스캐너 눈에서 **소멸**한다 → X1 이 "변이 핸들러 0건"으로 판정해
1149
+ * **가드가 없어도 통과**한다(실측). 반대 방향도 났다 — 정상 핸들러 본문에 그런 정규식이 있으면
1150
+ * 중괄호 짝이 어긋나 멀쩡한 코드가 error 가 됐다.
1151
+ *
1152
+ * ⚠ **리터럴을 지운 소스로 리터럴 "값"을 재려 하지 마라.** 여기를 거치면 `sameSite: "strict"` 가
1153
+ * `sameSite: " "` 가 된다 — 값 검사는 반드시 **원문**에 걸어야 한다(X3 가 그렇게 죽어 있었다).
1154
+ */
1155
+ function stripLiterals(code) {
1156
+ const out = code.split("");
1157
+ const blank = (from, to) => {
1158
+ for (let k = from; k < to && k < out.length; k++) if (out[k] !== "\n") out[k] = " ";
1159
+ };
1160
+ // `/` 가 정규식의 시작인지 나눗셈인지는 **직전 비공백 문자**로 가른다(표준 휴리스틱).
1161
+ // 아래 문자 뒤라면 값이 올 자리이므로 정규식이고, 그 밖(식별자·`)`·숫자 뒤)이면 나눗셈이다.
1162
+ const BEFORE_REGEX = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "~", "^", "<", ">"]);
1163
+ let prev = "";
1164
+ for (let i = 0; i < code.length; i++) {
1165
+ const c = code[i];
1166
+ if (c === '"' || c === "'" || c === "`") {
1167
+ const start = i;
1168
+ for (i++; i < code.length; i++) {
1169
+ if (code[i] === "\\") i++;
1170
+ else if (code[i] === c) break;
1171
+ }
1172
+ blank(start + 1, i); // 따옴표는 남긴다 — 토큰 경계가 무너지지 않게.
1173
+ prev = c;
1174
+ continue;
1175
+ }
1176
+ if (c === "/" && code[i + 1] === "/") {
1177
+ const start = i;
1178
+ i = code.indexOf("\n", i);
1179
+ if (i < 0) i = code.length;
1180
+ blank(start, i);
1181
+ continue; // prev 는 그대로 — 주석은 토큰이 아니다.
1182
+ }
1183
+ if (c === "/" && code[i + 1] === "*") {
1184
+ const start = i;
1185
+ i = code.indexOf("*/", i);
1186
+ if (i < 0) i = code.length;
1187
+ else i += 1;
1188
+ blank(start, i + 1);
1189
+ continue;
1190
+ }
1191
+ if (c === "/" && (prev === "" || BEFORE_REGEX.has(prev))) {
1192
+ // 정규식 리터럴. 줄바꿈을 못 넘으므로 **같은 줄 안에서만** 닫는 `/` 를 찾는다 —
1193
+ // 판정이 틀려도 폭주가 한 줄로 제한된다. 문자 클래스 `[...]` 안의 `/` 는 종료가 아니다.
1194
+ let j = i + 1;
1195
+ let inClass = false;
1196
+ for (; j < code.length && code[j] !== "\n"; j++) {
1197
+ if (code[j] === "\\") j++;
1198
+ else if (code[j] === "[") inClass = true;
1199
+ else if (code[j] === "]") inClass = false;
1200
+ else if (code[j] === "/" && !inClass) break;
1201
+ }
1202
+ if (j < code.length && code[j] === "/") {
1203
+ blank(i + 1, j); // 슬래시 둘은 남긴다.
1204
+ i = j;
1205
+ prev = "/";
1206
+ continue;
1207
+ }
1208
+ // 같은 줄에서 안 닫혔다 = 정규식이 아니었다. 나눗셈으로 두고 지나간다.
1209
+ }
1210
+ if (!/\s/.test(c)) prev = c;
1211
+ }
1212
+ return out.join("");
1213
+ }
1214
+
1215
+ /**
1216
+ * 변이 메서드 핸들러를 **선언 형태에 관계없이** 열거한다. 초판이 `export function` 만 봐서
1217
+ * 화살표 export 가 통째로 빠져나갔다(심의 실측).
1218
+ *
1219
+ * 입력은 [stripLiterals] 를 거친 소스여야 한다 — 그래야 문자열 안의 `export function POST(` 이
1220
+ * 유령 핸들러를 만들지 않고, 중괄호 짝도 리터럴에 흔들리지 않는다.
1221
+ *
1222
+ * 반환: `[{method, body}]`. `body` 는 중괄호 본문의 **안쪽 문자열**이고, 본문을 확정할 수
1223
+ * 없으면 `null` 이다(재export·간접 참조·중괄호 없는 화살표) — 그 형태는 호출부에서 error 로 다룬다.
1224
+ */
1225
+ function findMutationHandlers(code) {
1226
+ const found = [];
1227
+ const seen = new Set();
1228
+ const push = (method, body) => {
1229
+ if (seen.has(method)) return; // 같은 메서드를 두 번 세지 않는다.
1230
+ seen.add(method);
1231
+ found.push({method, body});
1232
+ };
1233
+
1234
+ // 리터럴이 이미 지워졌으므로 중괄호를 그대로 센다.
1235
+ const bodyAt = (open) => {
1236
+ let depth = 0;
1237
+ for (let i = open; i < code.length; i++) {
1238
+ if (code[i] === "{") depth++;
1239
+ else if (code[i] === "}" && --depth === 0) return code.slice(open + 1, i);
1240
+ }
1241
+ return null;
1242
+ };
1243
+
1244
+ // 시그니처의 여는 괄호 다음 위치에서 짝을 찾아 **매개변수 목록을 건너뛴다.**
1245
+ // 이걸 안 하면 `DELETE(req: Request, {params}: …)` 의 구조분해 중괄호를 본문으로 오인해
1246
+ // 정상 라우트가 전부 빨개진다(재작성 1차에서 실제로 그랬다).
1247
+ const afterParams = (openParen) => {
1248
+ let depth = 0;
1249
+ for (let i = openParen; i < code.length; i++) {
1250
+ if (code[i] === "(") depth++;
1251
+ else if (code[i] === ")" && --depth === 0) return i + 1;
1252
+ }
1253
+ return -1;
1254
+ };
1255
+
1256
+ const METHODS = "POST|PUT|PATCH|DELETE";
1257
+
1258
+ // ⒜ `export [async] function POST(...) {`
1259
+ for (const m of code.matchAll(new RegExp(`export\\s+(?:async\\s+)?function\\s+(${METHODS})\\s*\\(`, "g"))) {
1260
+ const sigEnd = afterParams(m.index + m[0].length - 1);
1261
+ const open = sigEnd < 0 ? -1 : code.indexOf("{", sigEnd);
1262
+ push(m[1], open < 0 ? null : bodyAt(open));
1263
+ }
1264
+
1265
+ // ⒝ `export const POST = async (req) => {` / `= async function (req) {` / 타입 주석 포함
1266
+ for (const m of code.matchAll(new RegExp(`export\\s+(?:const|let|var)\\s+(${METHODS})\\b[^=\\n]*=`, "g"))) {
1267
+ let i = m.index + m[0].length;
1268
+ const skip = (re) => {
1269
+ const t = code.slice(i).match(re);
1270
+ if (t) i += t[0].length;
1271
+ return !!t;
1272
+ };
1273
+ skip(/^\s+/);
1274
+ skip(/^async\s*/);
1275
+ const isFn = skip(/^function\s*\w*\s*/);
1276
+ let open = -1;
1277
+ if (code[i] === "(") {
1278
+ const sigEnd = afterParams(i);
1279
+ if (sigEnd > 0) {
1280
+ i = sigEnd;
1281
+ if (isFn) open = code.indexOf("{", i);
1282
+ else if (skip(/^\s*(?::[^=]*)?=>\s*/)) open = code[i] === "{" ? i : -1;
1283
+ }
1284
+ } else if (!isFn && skip(/^\w+\s*=>\s*/)) {
1285
+ open = code[i] === "{" ? i : -1;
1286
+ }
1287
+ push(m[1], open < 0 ? null : bodyAt(open));
1288
+ }
1289
+
1290
+ // ⒞ `export {POST}` · `export {handler as POST}` — 본문을 못 따라간다.
1291
+ for (const m of code.matchAll(/export\s*\{([^}]*)\}/g)) {
1292
+ for (const part of m[1].split(",")) {
1293
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
1294
+ if (name && new RegExp(`^(${METHODS})$`).test(name)) push(name, null);
1295
+ }
1296
+ }
1297
+
1298
+ return found;
1299
+ }
1300
+
1301
+ /**
1302
+ * 핸들러 본문 하나를 판정한다. 위반이면 사람이 읽을 사유 문자열, 통과면 `null`.
1303
+ *
1304
+ * 규칙은 **두 줄**이다 — 규칙 이름("가드는 첫 구문")과 구현이 어긋나지 않게 문자 그대로 잰다.
1305
+ * ① 본문의 **첫 구문**이 가드여야 한다(앞에 무엇이 오든 위반 — 열거식 금지 목록은 반드시 샌다).
1306
+ * ② 가드의 반환값이 **`return` 에 닿아야** 한다.
1307
+ *
1308
+ * ⚠ **②를 "관용구 일치"로 강제하면 안 된다.** 재작성 1차는 `const X = …; if (X) return X;` 를
1309
+ * 철자까지 요구했고, 그 결과 **정상 코드 6형태가 빨개졌다**(중괄호 `if`, 세미콜론 없는 스타일,
1310
+ * 타입 주석, 긴 주석, `!== null`, 네임스페이스 import — 심의 실측). 거짓 양성은 우회보다 위험하다:
1311
+ * 검사기가 정상 리팩터링을 막으면 사람이 면제 마커를 남발하거나 `validate` 를 꺼 버린다. 그 둘 다
1312
+ * memo118 §7-8 이 죽인 "사람이 관리하는 예외 목록"의 부활이다.
1313
+ */
1314
+ function judgeGuardPlacement(rawBody) {
1315
+ let body = rawBody;
1316
+ // `try { … }` 로 감싼 본문은 정당하다 — 가드는 여전히 먼저 돈다. 한 겹씩 들어간다.
1317
+ for (;;) {
1318
+ const head = body.match(/^\s*try\s*\{/);
1319
+ if (!head) break;
1320
+ body = body.slice(head[0].length);
1321
+ }
1322
+ const rest = body.replace(/^\s+/, "");
1323
+ const CALL = "(?:\\w+\\s*\\.\\s*)?assertSameOrigin\\s*\\("; // 네임스페이스 import 허용
1324
+
1325
+ // ① 첫 구문이 선언형 가드인가 — 타입 주석·세미콜론 유무를 묻지 않는다.
1326
+ const decl = rest.match(new RegExp(`^(?:const|let|var)\\s+(\\w+)\\b[^=;]*=\\s*${CALL}`));
1327
+ if (decl) {
1328
+ // ② 그 이름이 return 에 닿는가. `if (x) return x` · `if (x) { return x }` · `if (x !== null)`
1329
+ // 전부 이 한 줄로 통과한다.
1330
+ if (new RegExp(`return\\s+${decl[1]}\\b`).test(body)) return null;
1331
+ return (
1332
+ `가 assertSameOrigin 의 반환값을 차단에 쓰지 않습니다 — 호출만 있고 막지는 않는 상태입니다.` +
1333
+ ` \`const blocked = assertSameOrigin(req); if (blocked) return blocked;\` 형태를 쓰세요.`
1334
+ );
1335
+ }
1336
+
1337
+ // ① 첫 구문이 `if (assertSameOrigin(req)) …` 형태인 경우(직접 판정).
1338
+ //
1339
+ // ⚠ `return` 을 **본문 아무 데서나** 찾으면 안 된다 — 그러면 `if (가드) { /* 삼킨다 */ }` 뒤의
1340
+ // 정상 `return` 하나로 충족돼 **차단하지 않는 가드**가 통과한다(실측). "반환값 버림"이
1341
+ // if 갈래로 재발한 것이라, `return` 은 **그 if 의 본문 안**에서만 인정한다.
1342
+ if (new RegExp(`^if\\s*\\(\\s*${CALL}`).test(rest)) {
1343
+ if (new RegExp(`^if\\s*\\([\\s\\S]*?\\)\\s*(?:\\{[^}]*\\breturn\\b|\\breturn\\b)`).test(rest)) return null;
1344
+ return `가 가드에 걸린 요청을 return 으로 끊지 않습니다 — 차단이 성립하지 않습니다.`;
1345
+ }
1346
+
1347
+ // 가드가 어디에도 없다 vs 첫 구문이 아니다 — 사유를 갈라 준다(고치는 방법이 다르다).
1348
+ if (!new RegExp(CALL).test(body)) {
1349
+ return (
1350
+ `가 변이 메서드인데 assertSameOrigin 호출이 없습니다 — 교차사이트 위조가 열립니다(memo118).` +
1351
+ ` 정당한 예외면 파일 상단에 \`// zalkera-allow-cross-origin: 이유\` 를 다세요.`
1352
+ );
1353
+ }
1354
+ return (
1355
+ `의 assertSameOrigin 이 **본문의 첫 구문이 아닙니다** — 앞선 쿠키 쓰기는 403 응답에 Set-Cookie 를` +
1356
+ ` 실어 memo118 §3 의 불변식을 깹니다(실측 재현됨). 가드를 감싸거나(헬퍼·중첩 함수) 뒤로 미루지` +
1357
+ ` 말고 본문 맨 앞에 두세요.`
1358
+ );
1359
+ }
1360
+
1361
+ // ── X1: 교차사이트 위조 가드 (memo118) ─────────────────────────────
1362
+ //
1363
+ // 변이 메서드(POST·PUT·PATCH·DELETE)를 export 하는 라우트 핸들러는 **자기 본문의 첫 구문으로**
1364
+ // `assertSameOrigin` 을 불러야 한다. 경로 목록이 아니라 **메서드**로 판정하는 이유는,
1365
+ // 사람이 관리하는 위험 라우트 목록이 반드시 드리프트하기 때문이다 — 새 라우트가 자동 합류한다.
1366
+ //
1367
+ // 면제는 파일 상단 마커 한 줄로만 가능하고, **면제 목록을 항상 출력**한다(조용히 늘지 않게).
1368
+ // // zalkera-allow-cross-origin: 이유
1369
+ //
1370
+ // ⚠ **초판은 파일 단위 문자열 검사였고, 가드 없는 변이 라우트를 네 형태로 통과시켰다**(심의 실측):
1371
+ // ⒜ `export const POST = async (req) => …`(화살표라 `function` 정규식에 안 걸림) ⒝ 가드가 `GET`
1372
+ // 에만 있고 `POST` 는 무방비 ⒞ `assertSameOrigin(req);` 로 **반환값을 버림** ⒟ 가드가 쿠키 쓰기
1373
+ // **뒤**. ⒟ 는 특히 memo118 §3 의 실질 불변식을 깬다 — `cookies()` 변이는 뒤에 만든
1374
+ // `NextResponse` 에 그대로 합류하므로 **403 응답에 `Set-Cookie` 가 실린다**(실측 재현됨).
1375
+ // 그래서 판정을 파일이 아니라 **핸들러 본문 단위**로 올린다([judgeGuardPlacement]).
1376
+ //
1377
+ // ⚠ 이 규칙은 "가드를 올바른 자리에서 불렀는가"만 본다. 가드 **자체가 올바른가**는
1378
+ // `src/lib/crossOrigin.ts` 의 단위 테스트가 지킨다 — 특히 `Sec-Fetch-Site` 를 `!== "cross-site"`
1379
+ // 로 쓰면 플랫폼 존에서 형제 테넌트가 통과한다.
1380
+ function checkCrossOriginGuards() {
1381
+ const routes = [];
1382
+ const collect = (dir) => {
1383
+ let entries;
1384
+ try {
1385
+ entries = readdirSync(dir, {withFileTypes: true});
1386
+ } catch {
1387
+ return;
1388
+ }
1389
+ for (const e of entries) {
1390
+ const full = join(dir, e.name);
1391
+ if (e.isDirectory()) collect(full);
1392
+ else if (e.name === "route.ts" || e.name === "route.tsx") routes.push(full);
1393
+ }
1394
+ };
1395
+ // ⚠ `root` 는 **레포 루트가 아니라 소스 루트**(`./src`)다(82행). 초판은 `src/app/api` 도
1396
+ // 함께 걸었는데 그건 `./src/src/app/api` 라 존재하지 않는 죽은 경로였다.
1397
+ //
1398
+ // ⚠ **초판의 두 번째 좌표 오류 — `app/api` 만 걸었다.** Next 의 route handler 는 `app/` 아래
1399
+ // 어디에나 살 수 있고 이 레포에도 실물이 있다(`app/media/[id]/route.ts` — 오늘은 GET 전용이라
1400
+ // 피해 0). `app/upload/route.ts` 같은 자리에 변이 라우트가 생기면 검사기가 **존재 자체를
1401
+ // 모른다**. 경로가 아니라 메서드로 판정한다는 memo118 §5 원칙과도 전수 수집이 맞다.
1402
+ collect(join(root, "app"));
1403
+
1404
+ const exempted = [];
1405
+ for (const file of routes) {
1406
+ const code = readFileSync(file, "utf8");
1407
+ const rel = relative(root, file);
1408
+ const handlers = findMutationHandlers(stripLiterals(code));
1409
+ if (!handlers.length) continue;
1410
+
1411
+ // 면제 마커는 **파일 상단**에만 둔다 — 아무 데나 허용하면 주석·문자열 안의 한 줄로
1412
+ // 조용히 면제되고, 마커를 읽는 사람이 그 사실을 모른다. 첫 `export` 앞까지만 본다.
1413
+ const head = code.slice(0, code.search(/^export\b/m) + 1 || code.length);
1414
+ const marker = head.match(/\/\/\s*zalkera-allow-cross-origin:\s*(.+)/);
1415
+ if (marker) {
1416
+ exempted.push(`${rel} — ${marker[1].trim()}`);
1417
+ continue;
1418
+ }
1419
+
1420
+ for (const h of handlers) {
1421
+ const where = `[X1] ${rel} 의 ${h.method}`;
1422
+ if (h.body === null) {
1423
+ // 재export·간접 참조는 본문을 못 따라간다. 추측으로 통과시키면 그 형태가 곧
1424
+ // 우회로가 되므로, **핸들러를 이 파일에 직접 선언하라**고 요구한다.
1425
+ crossOriginSink().push(
1426
+ `${where} 가 본문을 따라갈 수 없는 형태로 export 됩니다(재export·간접 참조·중괄호 없는` +
1427
+ ` 화살표) — 가드 위치를 기계로 확인할 수 없습니다. 핸들러를 이 파일에 중괄호 본문으로` +
1428
+ ` 직접 선언하세요(memo118 §4).`,
1429
+ );
1430
+ continue;
1431
+ }
1432
+ const verdict = judgeGuardPlacement(h.body);
1433
+ if (verdict) crossOriginSink().push(`${where} ${verdict}`);
1434
+ }
1435
+ }
1436
+ if (exempted.length) {
1437
+ console.log(`교차사이트 가드 면제 ${exempted.length}건:`);
1438
+ for (const e of exempted) console.log(` · ${e}`);
1439
+ }
1440
+
1441
+ // X2 — 읽기 GET 면제는 "CORS 헤더가 없다"에 의존한다. 그 전제가 깨지면 면제도 깨진다.
1442
+ for (const file of routes) {
1443
+ const code = readFileSync(file, "utf8");
1444
+ if (/Access-Control-Allow-Origin/i.test(code)) {
1445
+ crossOriginSink().push(
1446
+ `[X2] ${relative(root, file)} 가 CORS 헤더를 답니다 — 읽기 GET 을 가드에서 빼는 근거가` +
1447
+ ` "교차 오리진 JS 가 응답을 못 읽는다"인데, 그 전제가 무너집니다(memo118 §7-2).`,
1448
+ );
1449
+ }
1450
+ }
1451
+
1452
+ // X3 — OAuth state 쿠키의 **1회용 소각**. 단위 테스트가 못 잠그는 자리다: `next/headers` 를
1453
+ // import 하는 모듈은 Node 기본 러너가 못 읽어서, 소각 한 줄을 지워도 27/27 이 그대로 통과한다
1454
+ // (심의 실측). 지워지면 state 가 유효기간(10분) 내내 재사용 가능해져 ②층의 리플레이 차단이
1455
+ // 조용히 사라진다. S8(테마 주입 배선)과 같은 계열의 "테스트가 못 닿는 배선" 검사다.
1456
+ //
1457
+ // ⚠ **파일 경로·상수 이름을 하드코딩하지 않는다.** 초판은 `lib/session.ts` 와
1458
+ // `OAUTH_STATE_COOKIE` 를 박아 뒀는데, 파일을 옮기거나 상수를 리네임하면 검사가 조용히
1459
+ // **fail-open** 했다(심의 실측: 소각 삭제 + 리네임 = 통과). `./src/src/app/api` 이후
1460
+ // 좌표가 죽어 검사가 사라진 **세 번째 사례**라 정의를 찾아가는 쪽으로 바꾼다.
1461
+ const sources = [];
1462
+ const collectSources = (dir) => {
1463
+ let entries;
1464
+ try {
1465
+ entries = readdirSync(dir, {withFileTypes: true});
1466
+ } catch {
1467
+ return;
1468
+ }
1469
+ for (const e of entries) {
1470
+ const full = join(dir, e.name);
1471
+ if (e.isDirectory()) collectSources(full);
1472
+ else if (/\.tsx?$/.test(e.name)) sources.push(full);
1473
+ }
1474
+ };
1475
+ collectSources(root);
1476
+
1477
+ let consumeDef = null;
1478
+ let usesStateCookie = false;
1479
+ for (const file of sources) {
1480
+ const raw = readFileSync(file, "utf8");
1481
+ const src = stripLiterals(raw);
1482
+ if (/\bconsumeOAuthState\s*\(/.test(src)) usesStateCookie = true;
1483
+ // 정의 형태를 묻지 않는다 — function 선언·화살표 상수 둘 다.
1484
+ const def = src.match(/(?:function\s+consumeOAuthState\b|consumeOAuthState\s*[:=][^=]*=>)/);
1485
+ if (def) consumeDef = {file, src, raw, at: def.index};
1486
+ }
1487
+
1488
+ if (usesStateCookie && !consumeDef) {
1489
+ crossOriginSink().push(
1490
+ `[X3] consumeOAuthState 를 부르는 곳은 있는데 **정의를 찾지 못했습니다** — state 쿠키 소각을` +
1491
+ ` 기계로 확인할 수 없습니다. 정의를 \`src/**\` 안에 두세요(검사가 조용히 사라지지 않게).`,
1492
+ );
1493
+ }
1494
+ if (consumeDef) {
1495
+ // 소각은 **그 함수 본문 안에서만** 찾는다 — 상수 이름은 묻지 않고 `.delete(...)` 호출만 본다.
1496
+ //
1497
+ // ⚠ 고정 길이 창(초판은 600자)으로 자르면 양쪽으로 틀린다: 소각을 지워도 **뒤 함수**의
1498
+ // `.delete(` 를 잘못 집어 통과하고(이 파일에는 실제로 `jar.delete(ACCESS_COOKIE)` 가 있다),
1499
+ // 반대로 함수 안 주석이 길면 소각이 창 밖으로 밀려 정상 코드가 error 가 된다
1500
+ // (`stripLiterals` 가 길이를 보존하므로 주석이 예산을 그대로 먹는다). 중괄호 짝으로 자른다.
1501
+ const open = consumeDef.src.indexOf("{", consumeDef.at);
1502
+ let fnBody = "";
1503
+ for (let i = open, depth = 0; i >= 0 && i < consumeDef.src.length; i++) {
1504
+ if (consumeDef.src[i] === "{") depth++;
1505
+ else if (consumeDef.src[i] === "}" && --depth === 0) {
1506
+ fnBody = consumeDef.src.slice(open + 1, i);
1507
+ break;
1508
+ }
1509
+ }
1510
+ if (!/\.delete\s*\(/.test(fnBody)) {
1511
+ crossOriginSink().push(
1512
+ `[X3] ${relative(root, consumeDef.file)} 의 consumeOAuthState 가 state 쿠키를 소각하지` +
1513
+ ` 않습니다 — 대조 통과 여부와 무관하게 지워야 1회용이 됩니다. 남기면 유효기간 동안` +
1514
+ ` 리플레이가 가능합니다(memo118 §2).`,
1515
+ );
1516
+ }
1517
+ // ⚠ **원문(raw)에 건다.** `stripLiterals` 를 거친 소스에서는 `sameSite: "strict"` 가
1518
+ // `sameSite: " "` 라 이 검사가 **어떤 파일에서도 매치되지 않는다** — 실제로 그렇게
1519
+ // 죽어 있었고(실측: `strict` 로 바꿔도 통과), memo118 §2 는 그 사이 "X3 가 막는다"고
1520
+ // 적어 뒀다. 값 검사는 원문, 코드 구조 검사는 stripped — 이 구분을 지켜라.
1521
+ if (/sameSite:\s*["']strict["']/i.test(consumeDef.raw)) {
1522
+ crossOriginSink().push(
1523
+ `[X3] ${relative(root, consumeDef.file)} 가 쿠키를 sameSite: "strict" 로 답니다 —` +
1524
+ ` authorize 리다이렉트로 **돌아올 때** 쿠키가 안 실려 정상 로그인이 깨집니다. 이 쿠키의` +
1525
+ ` 방어력은 SameSite 가 아니라 httpOnly + 서버 대조에서 나옵니다.`,
1526
+ );
1527
+ }
1528
+ }
1529
+ }
1530
+
1531
+ try {
1532
+ statSync(root);
1533
+ } catch {
1534
+ console.error(`디렉터리를 찾을 수 없습니다: ${root}`);
1535
+ process.exit(2);
1536
+ }
1537
+
1538
+ walk(root);
1539
+ checkLayoutBlastRadius(); // walk 가 layoutFiles 를 채운 뒤에.
1540
+ checkStyleWiring(); // S3·S5 — walk 가 cssFiles 를 채운 뒤에.
1541
+ checkThemeWiring(); // S8 — L1 배선(declared 전용).
1542
+ checkSectionCoverage();
1543
+ checkContentContract(); // N1~N5 — 콘텐츠 파일 계약(선언 조건화).
1544
+ checkDocCoordinates(); // D1·D2 — 문서 좌표가 실물을 가리키는가.
1545
+ checkCrossOriginGuards(); // X1·X2 — 교차사이트 위조 가드(memo118).
1546
+
1547
+ /**
1548
+ * `llms.txt` 운반본 드리프트 — **fail-soft**.
1549
+ *
1550
+ * 팩이 zip 루트에 `@zalkera/client` 의 llms.txt 를 바이트 그대로 싣는다(정본은 그 패키지 하나). 여기서는
1551
+ * 설치본과 대조해 갈라졌으면 **경고만** 한다:
1552
+ * · 파일이 **없으면 스킵** — 지운 것은 자유다(요건 1: 어휘를 강제할 수 없다). 없다고 벌하지 않는다.
1553
+ * · 있는데 다르면 경고 — 고객이 일부러 자기 메모를 적었을 수도 있고, 그것을 error 로 막으면
1554
+ * "우리 파일을 손대지 마라"가 되어 소유권 원칙과 충돌한다. 다만 **client 를 올린 뒤 사본을 안 고친**
1555
+ * 경우가 훨씬 흔하므로, 조용히 두면 명세가 낡은 채 배송된다.
1556
+ */
1557
+ function checkManualCarrier() {
1558
+ // ⚠ **절대 경로여야 한다.** 초판이 `join(root, "..")` 상대 경로를 `createRequire` 에 넘겨 조용히
1559
+ // catch 로 빠졌고, 사본을 훼손해도 경고가 안 났다(변이 실측). 있는데 안 도는 검사기가 없는 것보다 나쁘다.
1560
+ const projectRoot = resolve(root, "..");
1561
+ const at = join(projectRoot, "llms.txt");
1562
+ if (!existsSync(at)) return; // 없으면 스킵 — 지운 것은 자유.
1563
+ let carried;
1564
+ try {
1565
+ const req = createRequire(join(projectRoot, "package.json"));
1566
+ const anchorPath = req.resolve("@zalkera/client/contracts/aeo-surface-guarantees.json");
1567
+ carried = join(dirname(dirname(anchorPath)), "llms.txt");
1568
+ } catch {
1569
+ return; // client 미설치 — 여기서 판정할 것이 없다.
1570
+ }
1571
+ if (!existsSync(carried)) return;
1572
+ if (!readFileSync(at).equals(readFileSync(carried))) {
1573
+ warnings.push(
1574
+ "[W-LLMS] 루트 llms.txt 가 설치된 @zalkera/client 의 것과 다릅니다 — client 를 올린 뒤 사본을 " +
1575
+ "안 고쳤다면 낡은 명세가 배송됩니다. 일부러 고친 것이면 이 경고는 무시하십시오.",
1576
+ );
1577
+ }
1578
+ }
1579
+
1580
+ checkManualCarrier(); // W-LLMS — zip 이 나르는 명세가 설치본과 같은가(fail-soft).
1581
+
1582
+ if (!singletonFound) warnings.push(`[W1] createZalkeraClient 싱글턴을 찾지 못했습니다 — 서버 사이드 호출 패턴이 있는지 확인하세요.`);
1583
+
1584
+ console.log(
1585
+ `스타일 규약 모드: ${STYLE_MODE}` +
1586
+ (STYLE_MODE === "declared"
1587
+ ? " (tailwind-tokens 계약 — S2/S4 error 격상)"
1588
+ : STYLE_MODE === "inferred"
1589
+ ? " (tailwindcss 추론 — S 전부 warning)"
1590
+ : " (스택 미선언 — S 규칙 스킵)"),
1591
+ );
1592
+ console.log(
1593
+ `콘텐츠 규약 모드: ${CONTENT_MODE}` +
1594
+ (CONTENT_MODE === "declared"
1595
+ ? " (content=source 계약 — N 규칙 error)"
1596
+ : CONTENT_MODE === "inferred"
1597
+ ? " (content/pages 실재·선언 부재 — N 규칙 warning)"
1598
+ : " (콘텐츠 파일 계약 없음 — N 규칙 스킵)"),
1599
+ );
1600
+ for (const w of warnings) console.warn("⚠️ " + w);
1601
+ for (const e of errors) console.error("❌ " + e);
1602
+
1603
+ if (errors.length > 0) {
1604
+ console.error(`\n${errors.length}개 오류. 스토어프론트 규약 위반을 고치세요 (llms.txt §5 참고).`);
1605
+ process.exit(1);
1606
+ }
1607
+ console.log(`✅ 통과 — 검사한 규약 위반 없음${warnings.length ? ` (경고 ${warnings.length})` : ""}.`);