@zalkera/client 0.17.1 → 0.19.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.
@@ -8,16 +8,28 @@
8
8
  * 검사:
9
9
  * E1 "use client" 파일에서 @zalkera/client 를 값으로 import → baseUrl/토큰 노출 위험.
10
10
  * E2 "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera) import → 같은 위험.
11
+ * E3 **값**이 새는 형태 — `NEXT_PUBLIC_` 접두가 붙은 시크릿(소스·`.env*` 양쪽) · 소스에 박힌
12
+ * 스토어프론트 키 리터럴(`oqsk_…`). E1·E2 가 모듈만 보고 값은 아무도 안 보던 자리다.
13
+ * I1 warning — `x-forwarded-for` 의 **첫 엔트리 채택**(`xff.split(",")[0]` 계열). 첫 엔트리는 방문자가
14
+ * 요청에 손으로 실은 값이라 IP 레이트리밋이 한 줄로 우회된다. `visitorIp()` 로 대체한다. I 절 주석 참고.
11
15
  * W1 클라이언트 싱글턴 파일이 하나도 없음 → 서버 사이드 호출 패턴 미구현 의심.
12
16
  * C1 ISR-우선 게이트(memo31 §0-12) — SEO 라우트 page 가 per-page SSR(동적 렌더)을 강제하면 실패.
13
17
  * codegen 산출물이 홈·목록·상세·콘텐츠 페이지를 동적SSR 로 만들면 CI 를 red 로 만들어 미배포.
14
18
  * 정당화된 예외는 파일에 `// zalkera-allow-dynamic: <이유>` 마커를 두면 경고로 강등된다.
15
19
  * C1b layout 폭발반경 게이트 — layout/template 이 **import 로 도달하는 서버 모듈**에서 동적 API 를
16
20
  * 쓰면 실패. C1 과 달리 파일 하나가 아니라 import 그래프를 본다.
21
+ * C1p·C1pa·X1p **Pages Router**(`pages/**`) — 위 규칙들은 첫 세그먼트가 `app` 이어야 도는 App
22
+ * Router 전용이었고, Pages Router 레포에서는 서빙 책임 축이 통째로 안 재진 채 통과했다.
23
+ * C1p=`getServerSideProps` · C1pa=`_app`/`_document` 의 `getInitialProps`(사이트 전체 폭발반경) ·
24
+ * X1p=`pages/api` 가드 **유무**(위치는 못 잰다 — 그래서 경고다). 자세한 경계는 P 절 주석.
17
25
  * S1 error — .tsx 에서 죽은 레거시 토큰 `var(--oneq-` 참조. → `bg-primary`/`text-primary` 유틸리티로.
18
26
  * S2 warning — JSX 인라인 `style={{`(CSS 변수 주입 `style={{"--` 은 면제). 스타일은 유틸리티 클래스로.
19
27
  * S3 error — src/app/globals.css 부재 또는 root layout 이 그걸 import 하지 않음(배선 회귀 방지).
20
- * S4 warning — className 임의값(`bg-[#`·`text-[#`·`border-[#`). 테넌트 색은 토큰 경유가 규약.
28
+ * S4 warning — className 박힌 **색 리터럴**. 색을 싣는 유틸리티 전량(`bg`·`text`·`border`·`ring`·
29
+ * `outline`·`divide`·`placeholder`·`caret`·`accent`·`decoration`·`fill`·`stroke`·그라디언트
30
+ * `from`/`via`/`to`)에 hex·색 함수(`rgb(`·`hsl(`·`oklch(`…)·색 이름이 오면 잡는다. 값이 색이
31
+ * 아니면(`border-[2px]`·`to-[50%]`) 안 잡고, `var(…)`·`theme(…)` 경유는 면제다. 테넌트 색은
32
+ * 토큰 경유가 규약. 초판이 `-[#` 세 형태만 봐서 18가지가 새던 자리 — [COLOR_UTILITY_ROOTS] 참고.
21
33
  * S5 warning — src/app/globals.css 외의 .css 파일 존재(단일 CSS 원칙).
22
34
  * C2 error — 섹션 렌더러 switch 가 계약(@zalkera/client SECTION_CONTRACT)을 덮지 못함. 계약에 있는
23
35
  * 타입을 렌더러가 모르면 그 섹션은 **조용히 안 그려진다**(미지 타입 스킵이 계약이라 에러도 안 난다).
@@ -51,6 +63,8 @@
51
63
  * inferred 선언 부재 + content/pages/*.json 이 실재 W (형상은 있는데 선언이 없다)
52
64
  * none 그 외(다른 선언값 · content 디렉터리 없음) – (스킵)
53
65
  *
66
+ * N0 **은퇴한 선언값** `zalkera.content === "sections-db"` — 모드는 `none`(N1~N5 스킵)이지만 경고 한 줄을
67
+ * 낸다. 그 선언을 든 레포는 얼굴의 정본이 어디에도 없다(어휘 rev 7 · memo144).
54
68
  * N1 content/index.ts(매니페스트) 부재 — 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다.
55
69
  * N2 content/pages/*.json 파싱 실패 또는 최상위가 객체 아님.
56
70
  * N3 매니페스트와 파일의 어긋남 — 파일은 있는데 매니페스트에 없으면 **그 페이지는 존재하지 않는다**
@@ -63,6 +77,13 @@
63
77
  * 상품 `handle` 이 실재하는지는 **여기서 못 판정한다** — 카탈로그는 DB(레인 B)에 있다. 그 축의
64
78
  * 잣대는 산출물(개시된 사이트)이지 소스가 아니다.
65
79
  *
80
+ * O1 warning(**관문 모드에서도 경고 — 승격 영구 금지**) — `next.config` 에 `output: 'standalone'` 이
81
+ * 없다. 잘커라가 서빙하는 소스는 빌드가 `.next/standalone` 자기완결 산출물을 내야 하는데
82
+ * (우리 박스는 `node server.js` 로 띄운다·memo145), **설정 문자열은 그 사실을 재지 못한다** —
83
+ * 조건부 조립이면 키가 있어도 산출이 안 나오고 없어도 나올 수 있다(X1 과 같은 양방향 거짓).
84
+ * 그래서 정적으로 **확신할 수 있는 형상**(리터럴 객체 한 벌)에서만 말하고 동적이면 잠자코
85
+ * 스킵한다. 판정은 산출물이 사실인 자리에서만 한다(`verify-zip` ⑧ · CI · 서빙 게이트 exit 4).
86
+ * 자체 호스팅(BYO)이면 요건 자체가 없다.
66
87
  * D1 AGENTS.md 가 **없는 파일을 가리킴**. codegen 이 가장 먼저 읽는 문서라 죽은 좌표는 곧 탐색 토큰이다
67
88
  * (2026-07-30 기준선 실측: 낡은 좌표 때문에 에이전트가 콘텐츠 계약 대신 라우트를 새로 짰다).
68
89
  * D2 설치된 `@zalkera/client` 의 llms.txt 가 **본보기로 지목한 경로**가 이 레포에 없음 — 레시피가
@@ -115,8 +136,11 @@ function resolveSourceRoot() {
115
136
  const given = process.argv.slice(2).find((a) => !a.startsWith("-"));
116
137
  if (!given) return "./src";
117
138
  // 이미 소스 루트면 그대로. 아니면 그 아래 src/ 가 소스 루트인지 본다.
118
- if (existsSync(join(given, "app"))) return given;
119
- if (existsSync(join(given, "src", "app"))) return join(given, "src");
139
+ // ⚠ `pages` 도 함께 본다 — Pages Router 만 쓰는 레포(`app/` 없음)에서 판정이 레포 루트를
140
+ // 소스 루트로 오인했고, 그러면 `pages/` 찾는 좌표가 한 칸씩 어긋난다.
141
+ const isSourceRoot = (d) => existsSync(join(d, "app")) || existsSync(join(d, "pages"));
142
+ if (isSourceRoot(given)) return given;
143
+ if (isSourceRoot(join(given, "src"))) return join(given, "src");
120
144
  return given; // 둘 다 아니면 준 대로 두고 아래 존재 검사가 말하게 한다
121
145
  }
122
146
 
@@ -132,6 +156,122 @@ const FOREIGN_TOKEN_CLASSES = [
132
156
  "text-card-foreground", "text-popover-foreground", "text-muted-foreground", "text-accent-foreground",
133
157
  "text-destructive", "ring-ring", "border-input",
134
158
  ];
159
+
160
+ /*
161
+ * ── S4 의 어휘: 색을 싣는 유틸리티 · 색으로 읽히는 값 ────────────────────────────
162
+ *
163
+ * ⚠ **초판은 `bg-[#`·`text-[#`·`border-[#` 세 형태만 봤다.** 실측으로 18가지가 그대로 빠져나갔다
164
+ * (2026-08-01): 색 함수(`bg-[rgb(255,0,0)]`·`text-[hsl(…)]`·`bg-[oklch(…)]`), 색 이름(`bg-[red]`),
165
+ * 그리고 **접두가 다른 색 유틸리티 전부** — 그라디언트(`from-`·`via-`·`to-`), 링(`ring-`),
166
+ * 아웃라인·구분선·플레이스홀더·캐럿·강조(`outline-`·`divide-`·`placeholder-`·`caret-`·`accent-`),
167
+ * SVG(`fill-`·`stroke-`), 밑줄(`decoration-`). `bg-[#…]` 만 막고 `from-[#…]` 을 열어 두는 것은
168
+ * 규칙이 아니라 우연이다 — **`text-[#f00]` 를 고치라고 하면 `text-[rgb(255,0,0)]` 이 나온다.**
169
+ *
170
+ * 판정을 **접두 하나로** 하지 않고 접두 + **값의 모양** 둘로 하는 이유: 같은 접두가 색이 아닌 값도
171
+ * 싣는다. `border-[2px]`(두께) · `text-[14px]`(글자 크기) · `to-[50%]`(그라디언트 정지 위치) ·
172
+ * `stroke-[1.5]`(선 두께) · `bg-[url('/x.png')]`(배경 이미지) · `bg-[length:200px]`. 접두만 보면
173
+ * 이 전부가 빨개지고, 그러면 사람이 규칙을 끄지 정상 코드를 고치지 않는다.
174
+ *
175
+ * ⚠ **`shadow-` 는 일부러 뺐다** — 그림자 값(`shadow-[0_2px_8px_rgba(0,0,0,.1)]`)은 거의 항상 중립
176
+ * 검정이고 브랜드 색 표면이 아니다. 넣으면 정상 코드가 대량으로 빨개진다. 즉 이 축은 **안 잰다**.
177
+ */
178
+ const COLOR_UTILITY_ROOTS = new Set([
179
+ "bg", "text", "border", "ring", "outline", "decoration", "divide",
180
+ "placeholder", "caret", "accent", "fill", "stroke", "from", "via", "to",
181
+ ]);
182
+ /** 색 함수. `color-mix(` 까지 — Tailwind 임의값 안에서 전부 유효하다. */
183
+ const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\s*\(/;
184
+ /** `#rgb`~`#rrggbbaa`. 뒤에 영숫자가 더 붙으면 색이 아니다(해시 문자열 등). */
185
+ const HEX_COLOR = /#[0-9a-fA-F]{3,8}(?![0-9a-zA-Z])/;
186
+ /**
187
+ * CSS 색 이름 — **전수가 아니다**(표준은 148개). 브랜드색으로 실제로 쓰일 만한 것만 추렸다.
188
+ * 빠진 이름은 이 규칙이 **못 잡는다**; 전수를 실어도 되지만 그 목록은 사람이 관리하는 목록이 되고,
189
+ * 여기서 얻는 것보다 드리프트 비용이 크다. `transparent`·`currentColor`·`inherit` 은 색 결정이
190
+ * 아니라 위임이라 일부러 뺐다(Tailwind 에 `bg-transparent`·`text-current` 가 이미 있다).
191
+ */
192
+ const NAMED_COLORS = new Set([
193
+ "red", "blue", "green", "black", "white", "gray", "grey", "orange", "purple", "pink", "yellow",
194
+ "brown", "cyan", "magenta", "navy", "teal", "olive", "maroon", "lime", "aqua", "silver", "gold",
195
+ "indigo", "violet", "crimson", "coral", "salmon", "tomato", "khaki", "beige", "ivory", "azure",
196
+ "plum", "orchid", "turquoise", "lavender", "tan", "wheat", "snow", "skyblue", "hotpink",
197
+ "darkblue", "darkred", "darkgreen", "lightblue", "lightgray", "lightgrey", "midnightblue",
198
+ "steelblue", "slategray", "slategrey", "firebrick", "forestgreen", "seagreen", "royalblue",
199
+ "dodgerblue", "chocolate", "sienna", "peru",
200
+ ]);
201
+
202
+ /**
203
+ * 임의값 클래스 토큰 `[variant:]*<utility>-[<value>]`.
204
+ * 앞은 **단어·하이픈이 아닌 문자**로 끊어 식별자 중간(`my-bg-[…]`)에 걸리지 않게 한다. 변형 접두
205
+ * (`hover:`·`md:`·`dark:`)는 몇 겹이든 흡수한다 — `hover:bg-[#f00]` 도 같은 위반이다.
206
+ */
207
+ const ARBITRARY_CLASS = /(?:^|[^\w-])(?:[\w.-]+:)*!?(-?[a-z][a-zA-Z0-9-]*)-\[([^\]\s]*)\]/g;
208
+ /** 임의 **속성** 문법 `[color:#f00]`·`[background-color:red]` — 유틸리티 접두 없이 색을 박는 우회로. */
209
+ const ARBITRARY_COLOR_PROPERTY = /(?:^|[^\w-])(?:[\w.-]+:)*\[([a-zA-Z-]*color)\s*:\s*([^\]\s]*)\]/g;
210
+
211
+ /**
212
+ * 이 임의값이 **색 리터럴**인가. `var(…)`·`theme(…)` 은 토큰 경유라 규약이 권하는 길이고 면제다
213
+ * (`bg-[color:var(--brand)]` 는 위반이 아니다).
214
+ */
215
+ function isColorLiteral(value) {
216
+ if (/var\(|theme\(/.test(value)) return false;
217
+ if (HEX_COLOR.test(value) || COLOR_FUNCTION.test(value)) return true;
218
+ // `color:red` 처럼 타입 힌트가 앞에 붙은 형태에서 값만 떼어 본다.
219
+ return NAMED_COLORS.has(value.replace(/^[a-zA-Z-]+:/, "").toLowerCase());
220
+ }
221
+
222
+ /** 한 파일에서 색 리터럴을 박은 클래스 토큰 전량(중복 제거·정렬). */
223
+ function hardcodedColorClasses(text) {
224
+ const hits = new Set();
225
+ for (const m of text.matchAll(ARBITRARY_CLASS)) {
226
+ const [, utility, value] = m;
227
+ if (!COLOR_UTILITY_ROOTS.has(utility.split("-")[0])) continue;
228
+ if (isColorLiteral(value)) hits.add(`${utility}-[${value}]`);
229
+ }
230
+ for (const m of text.matchAll(ARBITRARY_COLOR_PROPERTY)) {
231
+ const [, property, value] = m;
232
+ if (isColorLiteral(value)) hits.add(`[${property}:${value}]`);
233
+ }
234
+ return [...hits].sort();
235
+ }
236
+
237
+ /*
238
+ * ── E3 : 시크릿이 브라우저 번들에 실린다 ────────────────────────────────────────
239
+ *
240
+ * E1·E2 는 **모듈**이 새는 형태(클라이언트 파일이 서버 클라이언트를 물었다)만 봤다. 정작 새는 것이
241
+ * **값**인 형태는 아무도 안 재고 있었다. `.env.example` 은 그 규약을 문장으로 적어 뒀는데
242
+ * ("`ZALKERA_STOREFRONT_KEY` 는 서버 전용 — `NEXT_PUBLIC_` 접두사 절대 금지") 기계는 한 번도 안 봤다.
243
+ *
244
+ * 이 축이 **사실 판정**인 이유: `NEXT_PUBLIC_` 은 취향이 아니라 Next 의 계약이다 — 그 접두가 붙은
245
+ * 환경변수는 빌드 시 값이 클라이언트 번들에 **문자 그대로 치환**된다. 그래서 "노출될 수 있다"가 아니라
246
+ * **이미 노출됐다**. 오탐 여지가 없으므로 X 축(이름을 재는 교차오리진 가드)과 달리 `servingSink` 다.
247
+ *
248
+ * ⚠ **못 재는 것**: 이름이 시크릿임을 안 밝힌 값(`NEXT_PUBLIC_FOO=oqsk_…`)은 이름만으로는 못 가른다.
249
+ * 그래서 **값 쪽 잣대**를 하나 더 둔다 — 소스에 박힌 `oqsk_` 리터럴. 둘 다 피한 유출은 못 잡는다.
250
+ */
251
+ /** 서버 전용임이 이름에 드러난 환경변수에 `NEXT_PUBLIC_` 이 붙은 형태. */
252
+ const PUBLIC_SECRET_ENV = /\bNEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|PRIVATE_KEY|STOREFRONT_KEY)[A-Z0-9_]*\b/g;
253
+ /** 스토어프론트 서버 시크릿 키 리터럴(memo78 `oqsk_…`). 소스에 박히면 그 자체로 유출이다. */
254
+ const STOREFRONT_KEY_LITERAL = /\boqsk_[A-Za-z0-9_-]{8,}/g;
255
+
256
+ /** 이 텍스트에서 발견된 시크릿 노출 형태의 사람용 사유 문자열들. */
257
+ function secretExposures(text) {
258
+ const out = [];
259
+ for (const name of new Set(text.match(PUBLIC_SECRET_ENV) ?? [])) {
260
+ out.push(
261
+ `${name} — NEXT_PUBLIC_ 접두가 붙은 값은 Next 가 브라우저 번들에 그대로 박습니다. ` +
262
+ `서버 전용 시크릿이면 접두를 떼고 서버(route handler·RSC)에서만 읽으세요.`,
263
+ );
264
+ }
265
+ if (STOREFRONT_KEY_LITERAL.test(text)) {
266
+ STOREFRONT_KEY_LITERAL.lastIndex = 0; // `g` 정규식의 상태를 다음 파일로 흘리지 않는다.
267
+ out.push(
268
+ `스토어프론트 서버 시크릿 키(oqsk_…)가 소스에 박혀 있습니다 — 소스는 zip 으로 유통되고 ` +
269
+ `레포에 남습니다. 값은 환경변수(ZALKERA_STOREFRONT_KEY)로만 넣고 즉시 재발급하세요.`,
270
+ );
271
+ }
272
+ return out;
273
+ }
274
+
135
275
  const errors = [];
136
276
  const warnings = [];
137
277
  const layoutFiles = [];
@@ -237,6 +377,108 @@ function crossOriginSink() {
237
377
  return warnings;
238
378
  }
239
379
 
380
+ /*
381
+ * ── I1 : 방문자 IP 를 `x-forwarded-for` **첫 엔트리**에서 뽑는다 ───────────────────────────
382
+ *
383
+ * `X-Forwarded-For` 는 **각 프록시가 자기가 받은 연결의 IP 를 오른쪽에 append** 하는 헤더다. 방문자가
384
+ * 요청에 `X-Forwarded-For: 9.9.9.9` 를 손으로 실으면 헤더는 `9.9.9.9, <진짜IP>` 가 되고, **첫 엔트리는
385
+ * 공격자가 쓴 문자열**이다. 첫 엔트리로 레이트리밋 버킷을 만들면 요청마다 값을 바꿔 버킷을 무한히 새로
386
+ * 만들 수 있다 — 2026-08-01 보안 심의가 상용 사이트에서 이 우회를 **실측**했다.
387
+ *
388
+ * **이 축은 이름이 아니라 사실을 잰다**(X1 과 다른 점). `headers.get("x-forwarded-for")…split(",")[0]`
389
+ * 이라는 표현식 **자체가** 첫 홉 채택이다 — "우리 심볼을 썼는가"가 아니라 무엇을 하는가를 본다.
390
+ * 걸리면 진짜다.
391
+ *
392
+ * ⚠ **못 잡는 것**(문서화된 음성 한계 — 선례는 E3 다): 값이 다른 파일을 거쳐 오는 경우, `[0]` 이 아닌
393
+ * 계산된 인덱스로 첫 엔트리를 집는 경우, 헤더 이름을 문자열 조립으로 만든 경우. v1 은 직접 표현식과
394
+ * **같은 파일 안 단순 변수 경유**까지만 좇는다. 잡지 못한 형상은 검사기가 조용한 것이지 안전한 것이 아니다.
395
+ *
396
+ * **왜 지금은 경고인가.** 관문으로 켜면 첫 동작이 **상용 자산 반려**다(우리 예제 레포와 상용 사이트가
397
+ * 아직 이 관용구를 쓴다 — 우리 교본이 그렇게 가르쳤기 때문이다). 자산을 먼저 일소하고(memo143 T4)
398
+ * 그 뒤에 [servingSink] 로 옮긴다. **승격 지점은 여기 한 줄**이다.
399
+ *
400
+ * 그리고 경고는 **받아 줄 곳을 가리켜야** 방치로 안 끝난다 — 메시지가 `visitorIp()` 를 지목한다.
401
+ */
402
+ function clientIpSink() {
403
+ return warnings; // T4(자산 일소) 뒤 `servingSink()` 로 승격 — memo143 §3-물음3·미결 3.
404
+ }
405
+
406
+ /**
407
+ * **서빙 산출물 계약 축(O)의 목적지 — 관문 모드에서도 경고다. 승격은 영구 금지다.**
408
+ *
409
+ * 계약 자체는 사실이다(memo145 §0): *"잘커라가 서빙하는 소스는 빌드가 `.next/standalone` 자기완결
410
+ * 산출물을 내야 한다."* 우리 박스는 `next start` 가 아니라 그 산출물을 `node server.js` 로 띄운다.
411
+ *
412
+ * **그런데 이 검사기는 그 사실을 잴 수 없다.** 여기서 읽을 수 있는 것은 `next.config` 라는 **설정 문자열**
413
+ * 이고, 그것은 산출물이 아니다. X1(교차사이트 가드)과 정확히 같은 함정이라 양쪽으로 틀린다:
414
+ * · **거짓 음성** — `output: "standalone"` 이 적혀 있어도 조건부 조립(`...(cond ? {} : {output:…})`)이면
415
+ * 산출이 안 나온다. **통과가 서빙됨을 뜻하지 않는다.**
416
+ * · **거짓 양성** — 키가 없어도 외부 조립·플러그인 래핑·재export 로 산출이 나올 수 있다.
417
+ * **반려가 결함을 뜻하지 않는다.**
418
+ *
419
+ * 그래서 이 축은 **경고까지만**이고, 관문은 산출물이 정의상 사실인 자리에만 둔다 — `verify-zip` ⑧
420
+ * (빌드 뒤 실물)·examples CI·서빙 박스 `build.sh` exit 4. 셋 다 **이미 지불한 빌드**를 읽는다.
421
+ *
422
+ * 그리고 하나 더: **자체 호스팅(BYO)에는 이 요건의 근거가 아예 없다**(memo140 §6.5 — 강제의 근거는
423
+ * 누가 서빙하는가다). Vercel·정적 export 로 사는 레포에 관문을 들이대면 멀쩡한 소스를 반려하게 된다.
424
+ * 그래서 메시지도 조건부로 말한다("잘커라 호스팅에 올릴 소스라면").
425
+ *
426
+ * ⚠ 이 함수를 `servingSink()` 로 바꾸지 마라. E·C 가 관문인 것과 다른 이유는 그 둘이 **소스에서 사실을
427
+ * 잴 수 있기** 때문이고, 이 축은 산출물에서만 사실이 나오기 때문이다.
428
+ */
429
+ function servingOutputSink() {
430
+ return warnings;
431
+ }
432
+
433
+ // ⚠ 아래 조각들은 **일반 문자열**로 쓴다. 정규식 리터럴·템플릿 리터럴 안에 백틱을 담을 수 없어서다
434
+ // (따옴표 세 종류를 다 흡수해야 하므로 백틱이 문자 클래스에 반드시 들어간다).
435
+ /** 문자열 리터럴을 여는/닫는 따옴표 한 글자 — `"` · `'` · 백틱(```). */
436
+ const QUOTE = '["\'\\u0060]';
437
+ /** 따옴표가 아닌 한 글자(리터럴 안쪽). */
438
+ const NOT_QUOTE = '[^"\'\\u0060]';
439
+
440
+ /** 헤더 이름 리터럴. Node(`req.headers["x-forwarded-for"]`)·web(`headers.get("x-forwarded-for")`) 양쪽을 흡수한다. */
441
+ const XFF_NAME = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}`, "i");
442
+
443
+ /** 콤마로 자르는 `split` 호출 — `split(",")`·`split(", ")`·`split(',')` 전부. */
444
+ const SPLIT_ON_COMMA = `\\.\\s*split\\s*\\(\\s*${QUOTE}${NOT_QUOTE}*,${NOT_QUOTE}*${QUOTE}\\s*\\)`;
445
+
446
+ /**
447
+ * 첫 엔트리 채택의 세 표기 — `[0]` · `.at(0)` · `.shift()`. 셋 다 같은 사실이다.
448
+ * **`.pop()`·`.at(-1)` 은 일부러 안 잡는다** — 마지막 엔트리는 첫 홉 위조와 다른 축이다(그쪽은 프록시가 쓴 값).
449
+ */
450
+ const FIRST_ENTRY = "(?:\\s*\\[\\s*0\\s*\\]|\\s*\\.\\s*at\\s*\\(\\s*0\\s*\\)|\\s*\\.\\s*shift\\s*\\(\\s*\\))";
451
+
452
+ /** 헤더 리터럴 → split → 첫 엔트리가 **한 표현식으로** 이어진 형태. 사이의 `?.`·`!`·`.trim()` 등은 흡수한다. */
453
+ const I1_DIRECT = new RegExp(
454
+ `${QUOTE}x-forwarded-for${QUOTE}[\\s\\S]{0,120}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`,
455
+ "i",
456
+ );
457
+
458
+ /** `const xff = <…x-forwarded-for…>` — 같은 파일 안 단순 변수 경유를 좇기 위한 바인딩 수집. */
459
+ const I1_BINDING = new RegExp(
460
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=[^;\\n]*${QUOTE}x-forwarded-for${QUOTE}`,
461
+ "gi",
462
+ );
463
+
464
+ /** 정규식 메타문자 이스케이프(식별자에 `$` 가 흔하다). */
465
+ function escapeRegExp(value) {
466
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
467
+ }
468
+
469
+ /** 이 텍스트가 첫 엔트리를 채택하는가. 직접 표현식 → 변수 경유 순으로 본다. 걸린 형상 문자열을 준다(없으면 null). */
470
+ function firstHopAdoption(text) {
471
+ if (!XFF_NAME.test(text)) return null; // 헤더를 아예 안 읽으면 이 축의 대상이 아니다(대다수 파일이 여기서 끝난다).
472
+ if (I1_DIRECT.test(text)) return "x-forwarded-for 를 읽어 바로 첫 엔트리를 채택";
473
+ I1_BINDING.lastIndex = 0;
474
+ for (const m of text.matchAll(I1_BINDING)) {
475
+ const name = escapeRegExp(m[1]);
476
+ const viaVariable = new RegExp(`\\b${name}\\b[\\s\\S]{0,40}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`);
477
+ if (viaVariable.test(text)) return `\`${m[1]}\`(x-forwarded-for 값)의 첫 엔트리를 채택`;
478
+ }
479
+ return null;
480
+ }
481
+
240
482
  const STYLE_MODE = detectStyleMode(root);
241
483
 
242
484
  /**
@@ -249,6 +491,10 @@ const STYLE_MODE = detectStyleMode(root);
249
491
  * declared : zalkera.content === "source" → N 규칙 error
250
492
  * inferred : 선언 부재 + content/pages/*.json → N 규칙 warning
251
493
  * none : 그 외 → N 규칙 스킵
494
+ *
495
+ * **은퇴한 선언값**(`"sections-db"`)은 3모드를 늘리지 않는다 — 판정은 그대로 `none` 이고 경고 한 줄을
496
+ * 더한다(어휘 rev 7 · memo144). 섹션이 백엔드 DB 에도 살던 시절의 값인데 그 거처가 통째로 퇴역해서,
497
+ * 그 선언을 든 레포는 **얼굴의 정본이 어디에도 없는 상태**다. 조용히 스킵하면 그 사실이 안 보인다.
252
498
  */
253
499
  function detectContentMode(srcDir) {
254
500
  const repoRoot = resolve(srcDir, "..");
@@ -270,7 +516,16 @@ function detectContentMode(srcDir) {
270
516
  dir = parent;
271
517
  }
272
518
  if (declared === "source") return "declared";
273
- if (declared !== undefined) return "none"; // sections-db 등 — 이 레포에 콘텐츠 파일 계약이 없다
519
+ if (declared === "sections-db") {
520
+ warnings.push(
521
+ "[N0] package.json 의 `zalkera.content` 가 은퇴한 값 `\"sections-db\"` 입니다 — " +
522
+ "섹션이 백엔드 DB 에 살던 시절의 표기이고 그 거처는 퇴역했습니다(어휘 rev 7). " +
523
+ "사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `\"source\"` 로 바꾸세요 " +
524
+ "(llms.txt §9.1).",
525
+ );
526
+ return "none";
527
+ }
528
+ if (declared !== undefined) return "none"; // 우리 계약이 아닌 선언값 — 이 레포에 콘텐츠 파일 계약이 없다
274
529
  return contentPageFiles(repoRoot).length > 0 ? "inferred" : "none";
275
530
  }
276
531
 
@@ -976,6 +1231,9 @@ function checkContentContract() {
976
1231
  // 필수 참조를 `requiredRefs`(이제 빈 배열)에서 이 키로 옮겼는데 이 검사기는 옛 키만 읽고 있었다 —
977
1232
  // **참조가 하나도 없는 섹션이 통과했다**(실측). N5 가 존재 이유로 삼는 바로 그 결함이 무검출이었다.
978
1233
  // 팩 게이트(`pack-preset.mjs`)는 anyOf 를 집행하고 있어 **어휘 사본 둘이 갈라진 상태**였다.
1234
+ // ⚠ rev 6 에서 그 두 타입이 어휘에서 삭제돼 **오늘 이 축을 쓰는 타입은 0 이다**(memo142).
1235
+ // 코드를 남기는 것은 계약 기계를 유지하기 위해서다 — 여기를 지우면 참조 필수 타입이 다시
1236
+ // 생기는 날 같은 무검출이 재발한다(그것이 이 주석이 기록하는 사고다).
979
1237
  for (const group of spec?.requiredRefsAnyOf ?? []) {
980
1238
  if (!group.some(isFilled)) {
981
1239
  const names = group.map(sourceKeyOf).join(" 또는 ");
@@ -1055,8 +1313,10 @@ function check(file) {
1055
1313
  }
1056
1314
 
1057
1315
  // S1: 죽은 레거시 토큰. globals.css @theme 로 부활한 유틸리티로 대체해야 한다.
1316
+ // ⚠ `.tsx` 만 보던 것을 `.jsx` 까지 넓혔다 — 타입스크립트를 안 쓰는 레포에서 같은 죽은 토큰이
1317
+ // 그대로 통과했다(BYO 는 JS 레포가 정상이다).
1058
1318
  const s1 = styleSink("S1");
1059
- if (s1 && /\.tsx$/.test(file) && /var\(--oneq-/.test(text)) {
1319
+ if (s1 && /\.[jt]sx$/.test(file) && /var\(--oneq-/.test(text)) {
1060
1320
  s1.push(
1061
1321
  `[S1] ${rel}: 죽은 레거시 토큰 var(--oneq-*) 참조. ` +
1062
1322
  `--oneq-primary → text-primary/bg-primary, --oneq-bg → text-primary-foreground 유틸리티로 대체하라(globals.css @theme).`,
@@ -1077,11 +1337,34 @@ function check(file) {
1077
1337
  }
1078
1338
 
1079
1339
  // S4: className 색 하드코딩(임의값). 테넌트 색은 primary 토큰 경유가 규약이다.
1340
+ // 어떤 형태를 잡고 무엇을 일부러 안 잡는지는 [COLOR_UTILITY_ROOTS] 위 주석에 있다.
1080
1341
  const s4 = styleSink("S4");
1081
- if (s4 && /(?:bg|text|border)-\[#/.test(text)) {
1082
- s4.push(
1083
- `[S4] ${rel}: className 색 임의값(bg-[#…]·text-[#…]·border-[#…]) — 브랜드색 하드코딩은 ` +
1084
- `콘솔의 '말로 색 바꾸기'를 무력화한다. bg-primary 등 토큰을, 중립은 slate 스케일을 쓰라.`,
1342
+ if (s4) {
1343
+ const hits = hardcodedColorClasses(text);
1344
+ if (hits.length > 0) {
1345
+ s4.push(
1346
+ `[S4] ${rel}: className 에 색 리터럴 — ${hits.slice(0, 6).join(" · ")}` +
1347
+ `${hits.length > 6 ? ` 외 ${hits.length - 6}건` : ""}. 브랜드색 하드코딩은 ` +
1348
+ `콘솔의 '말로 색 바꾸기'를 무력화한다. bg-primary 등 토큰을, 중립은 slate 스케일을, ` +
1349
+ `꼭 임의값이어야 하면 토큰 경유(bg-[color:var(--color-primary)])를 쓰라.`,
1350
+ );
1351
+ }
1352
+ }
1353
+
1354
+ // E3: 시크릿이 브라우저 번들에 실리는 형태. **사실을 잰다** — `NEXT_PUBLIC_` 접두는 Next 가
1355
+ // 빌드 시 값을 클라이언트 번들에 **문자 그대로 박아 넣는다**는 프레임워크 계약이라, 그 접두가
1356
+ // 붙은 시크릿은 "노출될 수도 있다"가 아니라 **이미 노출된 것**이다(.env.example §12 가 같은 말을
1357
+ // 문장으로 적어 뒀는데 검사기는 한 번도 안 봤다). 소스에 박은 키 리터럴도 같은 축이다.
1358
+ for (const hit of secretExposures(text)) servingSink().push(`[E3] ${rel}: ${hit}`);
1359
+
1360
+ // I1: 방문자 IP 를 XFF **첫 엔트리**에서 뽑는다 — 방문자가 위조할 수 있는 값이다(I 절 주석).
1361
+ // 경고이되 **받아 줄 곳을 가리킨다**: 대체물이 없는 경고는 방치로 끝난다.
1362
+ const firstHop = firstHopAdoption(text);
1363
+ if (firstHop) {
1364
+ clientIpSink().push(
1365
+ `[I1] ${rel}: ${firstHop} — **첫 엔트리는 방문자가 위조할 수 있습니다**(IP 레이트리밋 우회·IP 기록 오염). ` +
1366
+ `@zalkera/client 의 \`visitorIp(headers)\` 를 쓰고 프록시 홉 수를 선언하세요(기본 1). ` +
1367
+ `잘커라가 서빙하면 홉 1 이 구성상 참이라 기본값 그대로 맞습니다.`,
1085
1368
  );
1086
1369
  }
1087
1370
  }
@@ -1539,6 +1822,250 @@ function checkCrossOriginGuards() {
1539
1822
  }
1540
1823
  }
1541
1824
 
1825
+ /*
1826
+ * ── P : Pages Router (`pages/**`) ───────────────────────────────────────────
1827
+ *
1828
+ * ⚠ **이 검사기는 오늘까지 App Router 만 봤다.** `isSeoPageFile`·`isLayoutFile` 은 첫 세그먼트가
1829
+ * `app` 이어야 하고, `checkCrossOriginGuards` 는 `join(root,"app")` 밑의 `route.ts` 만 모은다.
1830
+ * 그래서 Pages Router 로 짠 레포에서는 **서빙 책임 축(C 동적 렌더 · X 교차 오리진)이 통째로
1831
+ * 안 재졌다** — 규칙이 없는 게 아니라 좌표가 안 닿아서 `✅ 통과` 가 찍혔다. 검사기가 죽는 것보다
1832
+ * 나쁜 상태다: 안 재고도 재는 척했다.
1833
+ *
1834
+ * Pages Router 는 우리 본보기의 형상이 아니지만 **우리 규칙이 아니다**(요건 1 — 어휘를 강제할 수
1835
+ * 없다). 고객이 `npx zalkera-validate` 를 자기 소스에 돌리고, 업로드 zip 이 그 소스일 수 있다.
1836
+ * 서빙 책임을 지는 자리에서 안 재는 라우트가 있으면 그건 그냥 구멍이다.
1837
+ *
1838
+ * 재는 것 / 안 재는 것을 못박아 둔다:
1839
+ * · C1p **잰다** — `getServerSideProps` 는 그 페이지를 매 요청 SSR 로 만든다(사실). App Router 의
1840
+ * C1 과 같은 축·같은 마커(`zalkera-allow-dynamic`)·같은 SEO 제외 세그먼트.
1841
+ * · C1pa **잰다** — `_app`/`_document` 의 `getInitialProps` 는 **사이트 전체**의 정적 최적화를 끈다.
1842
+ * 폭발반경이 C1b(layout) 과 같아서 따로 센다.
1843
+ * · X1p **경고만** — `pages/api` 핸들러는 하나의 default export 가 `req.method` 로 갈라지는 형태라,
1844
+ * App Router 의 X1 이 재는 "가드가 본문의 첫 구문인가"를 **여기서는 못 잰다**. 호출이 있는지
1845
+ * **유무만** 본다. 즉 **통과가 안전을 뜻하지 않는다** — X1 과 같은 이유로 관문에 안 올린다.
1846
+ * · E 별도 규칙을 두지 않는다. Pages Router 의 컴포넌트 모듈은 기본적으로 브라우저에 실리지만,
1847
+ * Next 가 `getServerSideProps`/`getStaticProps` 안에서만 쓰인 import 를 걷어내므로 "서버
1848
+ * 클라이언트를 import 했다"만으로는 유출을 **판정할 수 없다**. 값 축은 E3 가 전 파일에서 잰다.
1849
+ */
1850
+
1851
+ /** Pages Router 루트(`<src>/pages` 또는 레포 루트 `pages`). 없으면 null — 부재는 결함이 아니다. */
1852
+ function pagesRoot() {
1853
+ for (const cand of [join(root, "pages"), join(resolve(root, ".."), "pages")]) {
1854
+ try {
1855
+ if (statSync(cand).isDirectory()) return cand;
1856
+ } catch {
1857
+ /* 다음 후보 */
1858
+ }
1859
+ }
1860
+ return null;
1861
+ }
1862
+
1863
+ /** 디렉터리 아래 소스 파일 전량(재귀). `node_modules`·`.next` 는 건너뛴다. */
1864
+ function collectSourceFiles(dir, into = []) {
1865
+ let entries;
1866
+ try {
1867
+ entries = readdirSync(dir, {withFileTypes: true});
1868
+ } catch {
1869
+ return into;
1870
+ }
1871
+ for (const e of entries) {
1872
+ if (e.name === "node_modules" || e.name === ".next") continue;
1873
+ const full = join(dir, e.name);
1874
+ if (e.isDirectory()) collectSourceFiles(full, into);
1875
+ else if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name)) into.push(full);
1876
+ }
1877
+ return into;
1878
+ }
1879
+
1880
+ /** `pages/` 기준 상대 세그먼트가 SEO 제외 경로인가 — C1 과 **같은 목록**을 쓴다. */
1881
+ function isExcludedPagesRoute(pagesDir, file) {
1882
+ return relative(pagesDir, file)
1883
+ .split(sep)
1884
+ .slice(0, -1)
1885
+ .map(normalizeSegment)
1886
+ .some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
1887
+ }
1888
+
1889
+ function checkPagesRouter() {
1890
+ const pagesDir = pagesRoot();
1891
+ if (!pagesDir) return; // Pages Router 를 안 쓴다 — 잴 것이 없다.
1892
+
1893
+ const apiDir = join(pagesDir, "api");
1894
+ const isApi = (file) => !relative(apiDir, file).startsWith("..");
1895
+
1896
+ for (const file of collectSourceFiles(pagesDir)) {
1897
+ const rel = relative(process.cwd(), file);
1898
+ const raw = readFileSync(file, "utf8");
1899
+ const text = stripComments(raw);
1900
+ const stem = basename(file).replace(/\.[^.]+$/, "");
1901
+
1902
+ if (isApi(file)) {
1903
+ // ── X1p — 가드 **유무**만. 위치는 못 잰다(위 주석).
1904
+ const code = stripLiterals(raw);
1905
+ // 파일 상단 마커로 면제. X1 과 같은 마커·같은 "첫 export 앞까지" 규칙.
1906
+ const head = code.slice(0, code.search(/^export\b/m) + 1 || code.length);
1907
+ if (/\/\/\s*zalkera-allow-cross-origin:/.test(raw.slice(0, head.length || raw.length))) continue;
1908
+ // 비-GET 을 405 로 되돌리는 읽기 전용 라우트는 뺀다. 값 검사라 **원문**에 건다
1909
+ // (stripLiterals 를 거치면 `"GET"` 이 공백이 된다 — X3 가 그렇게 죽어 있었다).
1910
+ if (/\breq\.method\s*!==\s*["']GET["']/.test(raw)) continue;
1911
+ if (!/(?:\w+\s*\.\s*)?assertSameOrigin\s*\(/.test(code)) {
1912
+ crossOriginSink().push(
1913
+ `[X1p] ${rel}: pages/api 핸들러에 assertSameOrigin 호출이 없습니다 — 교차사이트 위조가 ` +
1914
+ `열립니다(memo118). 이 형태(default export + req.method 분기)에서는 검사기가 **가드의 ` +
1915
+ `위치를 못 잽니다** — 호출 유무만 봅니다. 읽기 전용이면 비-GET 을 405 로 되돌리거나 ` +
1916
+ `파일 상단에 \`// zalkera-allow-cross-origin: 이유\` 를 다세요.`,
1917
+ );
1918
+ }
1919
+ // X2 — 읽기 GET 면제의 전제(교차 오리진 JS 가 응답을 못 읽는다)가 CORS 헤더로 무너진다.
1920
+ if (/Access-Control-Allow-Origin/i.test(raw)) {
1921
+ crossOriginSink().push(
1922
+ `[X2] ${rel} 가 CORS 헤더를 답니다 — 읽기 GET 을 가드에서 빼는 근거가 무너집니다(memo118 §7-2).`,
1923
+ );
1924
+ }
1925
+ continue;
1926
+ }
1927
+
1928
+ // ── C1pa — `_app`/`_document` 의 getInitialProps: 사이트 전체 정적 최적화 해제.
1929
+ if (stem === "_app" || stem === "_document") {
1930
+ if (/\bgetInitialProps\b/.test(text)) {
1931
+ const allow = raw.match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
1932
+ const detail =
1933
+ `${rel}: ${stem} 이 getInitialProps 를 씁니다 — **사이트 전 라우트**의 자동 정적 최적화가 ` +
1934
+ `꺼져 모든 페이지가 매 요청 SSR 이 됩니다(폭발반경이 layout 과 같습니다)`;
1935
+ if (allow) warnings.push(`[C1pa] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
1936
+ else
1937
+ servingSink().push(
1938
+ `[C1pa] ${detail}. 데이터가 필요하면 페이지별 getStaticProps(ISR)로 내리고, ` +
1939
+ `꼭 필요하면 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하세요.`,
1940
+ );
1941
+ }
1942
+ continue;
1943
+ }
1944
+ if (stem.startsWith("_")) continue; // `_error`·`_middleware` 등 — 페이지가 아니다.
1945
+
1946
+ // ── C1p — SEO 라우트가 getServerSideProps 로 매 요청 SSR 을 강제.
1947
+ if (isExcludedPagesRoute(pagesDir, file)) continue;
1948
+ const gssp =
1949
+ /export\s+(?:async\s+)?function\s+getServerSideProps\b/.test(text) ||
1950
+ /export\s+(?:const|let|var)\s+getServerSideProps\b/.test(text) ||
1951
+ /export\s*\{[^}]*\bgetServerSideProps\b/.test(text);
1952
+ if (!gssp) continue;
1953
+ const allow = raw.match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
1954
+ const detail = `${rel}: SEO 라우트가 getServerSideProps 로 매 요청 SSR 을 강제합니다`;
1955
+ if (allow) warnings.push(`[C1p] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
1956
+ else
1957
+ servingSink().push(
1958
+ `[C1p] ${detail}(memo31 §0-12). getStaticProps + revalidate(ISR)로 바꾸고, 실시간·개인화 값은 ` +
1959
+ `클라이언트에서 가져오세요. 꼭 필요하면 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하세요.`,
1960
+ );
1961
+ }
1962
+ }
1963
+
1964
+ /*
1965
+ * ── E3 의 나머지 절반 : `.env*` 파일 ─────────────────────────────────────────
1966
+ *
1967
+ * 이름을 바꿔서 새는 사고는 소스가 아니라 **환경 파일**에서 난다 — "서버에서 못 읽네" 하고
1968
+ * `NEXT_PUBLIC_` 을 붙이는 순간 그 값은 번들에 박힌다. zip 에 `.env` 가 섞여 오는 일도 실제로 있다.
1969
+ * 값은 안 본다(형태만 본다) — 검사기가 시크릿 값을 읽어 출력에 실으면 그게 또 하나의 유출이다.
1970
+ */
1971
+ function checkEnvFiles() {
1972
+ const repoRoot = resolve(root, "..");
1973
+ let names;
1974
+ try {
1975
+ names = readdirSync(repoRoot).filter((n) => n === ".env" || n.startsWith(".env."));
1976
+ } catch {
1977
+ return;
1978
+ }
1979
+ for (const name of names.sort()) {
1980
+ let lines;
1981
+ try {
1982
+ lines = readFileSync(join(repoRoot, name), "utf8").split("\n");
1983
+ } catch {
1984
+ continue;
1985
+ }
1986
+ for (const line of lines) {
1987
+ if (/^\s*#/.test(line)) continue; // 주석 — `.env.example` 이 금지 규약을 문장으로 적어 뒀다.
1988
+ const m = line.match(PUBLIC_SECRET_ENV);
1989
+ PUBLIC_SECRET_ENV.lastIndex = 0;
1990
+ if (m) {
1991
+ servingSink().push(
1992
+ `[E3] ${name}: ${m[0]} — NEXT_PUBLIC_ 접두가 붙은 값은 브라우저 번들에 그대로 박힙니다. ` +
1993
+ `서버 전용 시크릿이면 접두를 떼세요(값이 이미 유통됐다면 재발급까지).`,
1994
+ );
1995
+ }
1996
+ }
1997
+ }
1998
+ }
1999
+
2000
+ /*
2001
+ * ── O1 : 서빙 산출물 계약 — `next.config` 가 standalone 을 안 낸다(경고 전용) ──────────────
2002
+ *
2003
+ * **못 재는 것을 잰 척하지 않는 것**이 이 규칙의 설계 전부다([servingOutputSink] 참조). 그래서 판정을
2004
+ * 두 단계로 나눈다:
2005
+ *
2006
+ * 1. **확신할 수 있는 형상인가** — 설정이 *정적 리터럴 객체 한 벌*인가. 스프레드·삼항·함수형 config·
2007
+ * 플러그인 래핑(`withMDX(config)`)·`process.env`·`require` 가 보이면 **잠자코 스킵한다.** 그 형상에서
2008
+ * "키가 없다"는 산출이 안 나온다는 뜻이 아니다.
2009
+ * 2. 확신할 수 있을 때만 키를 본다 — 없으면(또는 `output:"export"` 면) 경고.
2010
+ *
2011
+ * 스킵이 조용한 이유: 여기서 "동적이라 못 쟀습니다"를 매번 찍으면 정상적으로 플러그인을 쓰는 레포가
2012
+ * 영구히 시끄러워지고, 그러면 사람이 경고 전체를 무시하기 시작한다. 진실은 몇 분 뒤 빌드가 말해 준다.
2013
+ */
2014
+ const NEXT_CONFIG_NAMES = ["next.config.ts", "next.config.mts", "next.config.js", "next.config.mjs", "next.config.cjs"];
2015
+
2016
+ /**
2017
+ * 이 설정 파일이 **정적으로 판독 가능한가**. 하나라도 걸리면 못 읽는 것으로 본다(거짓 경고 금지).
2018
+ * · `...` 스프레드 조립 · `?` 삼항(그리고 TS optional) — 값이 갈린다
2019
+ * · `=>`·`function` 함수형 config · `require(`·`process.env`·백틱 런타임 값
2020
+ * · `export default <ident>(` 플러그인 래핑(`withMDX(config)`)
2021
+ * · 타입이 아닌 import 다른 모듈이 설정을 만든다는 신호
2022
+ */
2023
+ function isStaticallyReadableConfig(text) {
2024
+ if (/\.\.\.|=>|\bfunction\b|\brequire\s*\(|process\.env|`|\?/.test(text)) return false;
2025
+ if (/export\s+default\s+[A-Za-z_$][\w$]*\s*\(/.test(text)) return false;
2026
+ if (/module\.exports\s*=\s*[A-Za-z_$][\w$]*\s*\(/.test(text)) return false;
2027
+ // `import type {NextConfig} from "next"` 은 값을 안 나른다 — 그것만 허용한다.
2028
+ for (const m of text.matchAll(/^\s*import\s+([^;\n]*)from\s/gm)) {
2029
+ if (!/^\s*type\b/.test(m[1])) return false;
2030
+ }
2031
+ return true;
2032
+ }
2033
+
2034
+ function checkServingOutputContract() {
2035
+ const repoRoot = resolve(root, "..");
2036
+ const found = NEXT_CONFIG_NAMES.map((n) => join(repoRoot, n)).filter((p) => existsSync(p));
2037
+ // 설정 파일이 여럿이면 어느 것이 유효한지 Next 의 해석 순서에 달렸다 — 못 가르는 자리라 스킵한다.
2038
+ if (found.length !== 1) return;
2039
+
2040
+ let text;
2041
+ try {
2042
+ text = stripComments(readFileSync(found[0], "utf8"));
2043
+ } catch {
2044
+ return;
2045
+ }
2046
+ if (!isStaticallyReadableConfig(text)) return; // 동적 조립 — 잠자코 스킵.
2047
+
2048
+ const name = basename(found[0]);
2049
+ const advice =
2050
+ `잘커라 호스팅에 올릴 소스라면 ${name} 에 output: 'standalone' 이 필요합니다 ` +
2051
+ `(우리 박스는 next start 가 아니라 빌드 산출물 .next/standalone/server.js 를 실행합니다). ` +
2052
+ `자체 호스팅이면 무관합니다. ` +
2053
+ `⚠ 이 검사는 설정 문자열만 봅니다 — 판정은 빌드 산출물이 합니다(verify-zip·CI·서빙 게이트).`;
2054
+
2055
+ const m = text.match(/(?:^|[{,;\s])output\s*:\s*(["'])([^"']*)\1/);
2056
+ if (m) {
2057
+ if (m[2] === "standalone") return; // 계약을 지키는 형태 — 조용히 통과(사실 판정은 빌드가 한다).
2058
+ // `export` 같은 리터럴 값은 **자기완결 산출물을 못 낸다**는 사실이 확실하다.
2059
+ servingOutputSink().push(`[O1] ${name}: output: '${m[2]}' — ${advice}`);
2060
+ return;
2061
+ }
2062
+ // `output:` 자체가 없다(리터럴에서 부재가 확실). 값이 식별자·객체면 위 정규식이 안 잡는데,
2063
+ // 그 형상은 위 [isStaticallyReadableConfig] 를 이미 통과 못 한다(변수 경유는 import·삼항 없이는
2064
+ // 나오기 어렵다) — 남는 형상은 실제 부재이거나 `output: someVar` 뿐이라, 후자를 위해 한 번 더 본다.
2065
+ if (/(?:^|[{,;\s])output\s*:/.test(text)) return; // 값이 리터럴이 아니다 — 못 잰다.
2066
+ servingOutputSink().push(`[O1] ${name}: output 설정이 없습니다 — ${advice}`);
2067
+ }
2068
+
1542
2069
  try {
1543
2070
  statSync(root);
1544
2071
  } catch {
@@ -1553,7 +2080,10 @@ checkThemeWiring(); // S8 — L1 배선(declared 전용).
1553
2080
  checkSectionCoverage();
1554
2081
  checkContentContract(); // N1~N5 — 콘텐츠 파일 계약(선언 조건화).
1555
2082
  checkDocCoordinates(); // D1·D2 — 문서 좌표가 실물을 가리키는가.
1556
- checkCrossOriginGuards(); // X1·X2 — 교차사이트 위조 가드(memo118).
2083
+ checkCrossOriginGuards(); // X1·X2·X3 — 교차사이트 위조 가드(memo118).
2084
+ checkPagesRouter(); // C1p·C1pa·X1p — Pages Router 좌표(App Router 전용이던 사각).
2085
+ checkEnvFiles(); // E3 — `.env*` 의 NEXT_PUBLIC_ 시크릿.
2086
+ checkServingOutputContract(); // O1 — 서빙 산출물 계약(경고 전용·관문 승격 영구 금지).
1557
2087
 
1558
2088
  /**
1559
2089
  * `llms.txt` 운반본 드리프트 — **fail-soft**.