@zalkera/client 0.22.4 → 0.23.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.
@@ -124,10 +124,26 @@
124
124
  * 이 파일은 **인자로 받은 소스 디렉터리 기준으로만** 동작한다(레포 고정 경로 0). 그래서 어느
125
125
  * 체크아웃에서든, 압축을 푼 zip 안에서든 똑같이 돈다.
126
126
  */
127
- import {existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync} from "node:fs";
127
+ import {existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync} from "node:fs";
128
128
  import {createRequire} from "node:module";
129
129
  import {basename, dirname, isAbsolute, join, relative, resolve, sep} from "node:path";
130
130
 
131
+ /**
132
+ * 이 디렉터리가 **라우트를 담고 있는가** — 이름만 맞는 빈 껍데기가 아닌가.
133
+ *
134
+ * 한 단계만 본다(재귀하지 않는다): 라우트 파일이 바로 아래에 있거나, 하위 디렉터리가 하나라도
135
+ * 있으면 라우트 뿌리로 친다. 빈 디렉터리·잡파일만 있는 디렉터리는 아니다.
136
+ */
137
+ function holdsRoutes(dir) {
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(dir, {withFileTypes: true});
141
+ } catch {
142
+ return false;
143
+ }
144
+ return entries.some((e) => e.isDirectory() || /^(page|layout|route|template|default)\.[jt]sx?$/.test(e.name));
145
+ }
146
+
131
147
  /**
132
148
  * 검사할 **소스 루트**(`src/`). 세 가지를 흡수한다 — 셋 다 실제로 사람이 치는 형태다.
133
149
  *
@@ -145,7 +161,14 @@ function resolveSourceRoot() {
145
161
  // 이미 소스 루트면 그대로. 아니면 그 아래 src/ 가 소스 루트인지 본다.
146
162
  // ⚠ `pages` 도 함께 본다 — Pages Router 만 쓰는 레포(`app/` 없음)에서 이 판정이 레포 루트를
147
163
  // 소스 루트로 오인했고, 그러면 `pages/` 를 찾는 좌표가 한 칸씩 어긋난다.
148
- const isSourceRoot = (d) => existsSync(join(d, "app")) || existsSync(join(d, "pages"));
164
+ // **여기만 봉쇄를 쓴다.** 판정이 레포 루트(`repoRootDir`)를 정하는 입력이라, 봉쇄가
165
+ // 기준으로 삼을 루트가 아직 없다. 심링크된 `app`/`pages` 는 소스 루트 선택을 흔들 수 있지만
166
+ // 그 결과는 **어느 디렉터리를 훑을지**일 뿐이고, 훑기 자체는 아래에서 전부 봉쇄를 지난다.
167
+ //
168
+ // ⚠ **이름만으로 정하지 않는다.** 빈 `pages/` 하나를 레포 루트에 두면 그것이 `src/app` 을 이겨
169
+ // 소스 루트가 미끄러진다 — 그러면 실제 코드의 경로 첫 조각이 `src` 가 되어 라우트 규칙이
170
+ // 하나도 안 걸리고, 관문은 초록을 찍는다(실측). **라우트 파일이 실제로 있는지**까지 본다.
171
+ const isSourceRoot = (d) => holdsRoutes(join(d, "app")) || holdsRoutes(join(d, "pages"));
149
172
  if (isSourceRoot(given)) return given;
150
173
  if (isSourceRoot(join(given, "src"))) return join(given, "src");
151
174
  return given; // 둘 다 아니면 준 대로 두고 아래 존재 검사가 말하게 한다
@@ -243,7 +266,12 @@ process.on("uncaughtException", (err) => {
243
266
  process.exit(2);
244
267
  });
245
268
 
246
- const root = resolveSourceRoot();
269
+ // **절대경로로 편다.** `node_modules` 스킵 판정이 `dirname(full) === repoRootDir` 인데, 인자를
270
+ // 상대로 주면(`--gate .`) 좌변은 상대(`"."`)이고 우변은 절대라 **영구 거짓**이 된다 — 스킵이 한
271
+ // 번도 안 되어 BYO 레포가 rc=7 이었고(`typescript.js` 9MB > 상한 8MB), 우리 배송 파일의 예시
272
+ // 문자열이 `[E3]` 시크릿 유출로 오보됐다(실측). 시험이 전부 `mkdtempSync` 절대경로를 넘겨
273
+ // 못 잡았다. 여기서 한 번 펴면 아래 좌표 비교가 전부 같은 축에서 돈다.
274
+ const root = resolve(resolveSourceRoot());
247
275
 
248
276
  /**
249
277
  * 레포 루트. **소스 루트의 부모가 아니다** — 소스 루트가 레포 루트 자신일 수 있다.
@@ -254,6 +282,32 @@ const root = resolveSourceRoot();
254
282
  * `--gate .` 경로에서 **N1 거짓 오류로 관문이 섰다**(두 심의자가 독립적으로 같은 자리를 지목).
255
283
  *
256
284
  * 좌표가 흩어져 있던 것이 구조적 원인이라 **한 자리에서 정한다.**
285
+ *
286
+ * ⚠ **이 판정은 이름에 걸려 있고, 그것이 알려진 한계다.** 레포 루트 자신의 이름이 `src` 이면
287
+ * 판정이 레포 루트를 **레포 밖**으로 밀어, `containedPath`·`walkTree` 가 세운 봉쇄가 그 한 칸만큼
288
+ * 헐거워진다. 실측(`parent/src` 가 레포 루트, `--gate .`):
289
+ *
290
+ * parent/.env → ❌[E3] 이 레포의 유출로 오보
291
+ * parent/src/app/escaped -> ../../OUTSIDE
292
+ * → 심링크를 따라가 레포 밖 파일 내용을 관문 출력에 실었다
293
+ *
294
+ * **표지(`package.json`)로 정하는 판을 넣었다가 되돌렸다.** 그 판은 이 형상 하나를 닫는 대신
295
+ * 셋을 열었다(전부 실측):
296
+ *
297
+ * src/package.json 한 장(`{"type":"module"}` 은 정상 관용구) → E3·D1·O1 이 통째로 침묵
298
+ * 부모에 package.json (이름 무관) → 같은 이탈이 **모든 트리**로 확대
299
+ * 표지가 어느 쪽에도 없는 트리 → E3 침묵
300
+ *
301
+ * 침묵 둘은 관문이 **거짓 초록**을 찍는 형상이라 이탈보다 나쁘고, 이탈 하나는 폭발반경이 더 넓다.
302
+ * 그래서 **좁고 알려진 이탈**을 남기는 쪽으로 되돌렸다.
303
+ *
304
+ * ⚠ 이 자리의 정석은 추측을 그만두는 것이다 — 레포 루트는 부른 사람이 안다(`build.sh` 는 `/build` 로
305
+ * `cd` 한다). 다만 그것은 호출 계약 변경이라 오너 판단 사항으로 남긴다. 그때까지 **판정이 틀렸을 때
306
+ * 무증상인 것**이 이 함수의 진짜 결함이다.
307
+ *
308
+ * ⚠ 서빙 관문에는 이 이탈이 닿지 않는다 — `sandbox/build.sh` 가 `/build` 에서 돌고 관문보다 먼저
309
+ * `npm ci` 를 요구하므로 소스 루트 이름이 `build` 다(두 심의관 독립 확인). 닿는 경로는 고객·코딩
310
+ * 에이전트가 자기 레포를 `src` 라는 이름으로 두고 `npx zalkera-validate` 를 부르는 자리다.
257
311
  */
258
312
  function resolveRepoRoot(sourceRoot) {
259
313
  return basename(sourceRoot) === "src" ? resolve(sourceRoot, "..") : resolve(sourceRoot);
@@ -266,20 +320,22 @@ const repoRootDir = resolveRepoRoot(root);
266
320
  * 않는다 — 색이 빠진 채로 조용히 배포되는 종류의 사고라 declared 모드에서 error 다.
267
321
  * 주의: 우리 `muted` 는 **글자색**이라 `bg-muted`(shadcn 은 배경)와 의미가 다르다.
268
322
  */
323
+ //
324
+ // ⚠ **완성 토큰을 소스에 적지 않는다.** 이 배열이 `"bg-card",` 꼴이던 동안, 이 파일 자신이 S6 의
325
+ // 잣대(`(^|[\s"\'`])토큰(?![\w-])`)에 걸렸다. 이 패키지를 `vendor/` 로 반입한 declared 레포는
326
+ // 우리 파일까지 훑으므로 그 레포의 관문이 rc=1 이 됐고(실측: 반입 델타가 정확히 `[S6]` 한 줄),
327
+ // 처방은 **우리 배송 파일을 고치라**고 시켰다. S6 에는 면제 마커가 없어 참 사유로 빠져나갈 길도
328
+ // 없다. `oqsk_` 예시 키(E3)와 같은 종류의 자충수다.
329
+ //
330
+ // 접두와 이름을 나눠 적고 여기서 합친다 — shadcn 어휘가 실제로 `{유틸리티}-{토큰}` 구조라
331
+ // 구조를 드러내는 쪽이기도 하다. 배송물 전체가 자기 관문을 통과하는지는
332
+ // `src/validate-shipped-artifact.test.ts` 가 **관문을 실제로 돌려** 잰다.
269
333
  const FOREIGN_TOKEN_CLASSES = [
270
- "bg-card",
271
- "bg-popover",
272
- "bg-muted",
273
- "bg-accent",
274
- "bg-destructive",
275
- "text-card-foreground",
276
- "text-popover-foreground",
277
- "text-muted-foreground",
278
- "text-accent-foreground",
279
- "text-destructive",
280
- "ring-ring",
281
- "border-input",
282
- ];
334
+ ["bg", ["card", "popover", "muted", "accent", "destructive"]],
335
+ ["text", ["card-foreground", "popover-foreground", "muted-foreground", "accent-foreground", "destructive"]],
336
+ ["ring", ["ring"]],
337
+ ["border", ["input"]],
338
+ ].flatMap(([utility, tokens]) => tokens.map((token) => `${utility}-${token}`));
283
339
 
284
340
  /*
285
341
  * ── S4 의 어휘: 색을 싣는 유틸리티 · 색으로 읽히는 값 ────────────────────────────
@@ -391,11 +447,39 @@ const NAMED_COLORS = new Set([
391
447
  /**
392
448
  * 임의값 클래스 토큰 `[variant:]*<utility>-[<value>]`.
393
449
  * 앞은 **단어·하이픈이 아닌 문자**로 끊어 식별자 중간(`my-bg-[…]`)에 걸리지 않게 한다. 변형 접두
394
- * (`hover:`·`md:`·`dark:`)는 겹이든 흡수한다 — `hover:bg-[#f00]` 도 같은 위반이다.
450
+ * (`hover:`·`md:`·`dark:`)는 여러 겹을 흡수한다 — `hover:bg-[#f00]` 도 같은 위반이다.
451
+ *
452
+ * ⚠ **모든 반복에 상한을 건다 — 상한이 없으면 2차다.** 무한 수량자는 닫는 `]` 나 유틸리티 이름을
453
+ * 못 찾을 때 시작 오프셋마다 파일 끝까지 훑고 되돌아온다. 실측(정규식 단독, 한 파일):
454
+ *
455
+ * "text-[" 반복 100KB 1,199ms → 400KB 19,092ms (값 `[^\]\s]*`)
456
+ * "[color:" 반복 100KB 2,381ms → 400KB 38,102ms (값 `[^\]\s]*`)
457
+ * "a:" 반복 100KB 9,220ms → 400KB 156,608ms (접두 `(?:[\w.-]+:)*`)
458
+ *
459
+ * 배증당 ×3.8~×17 이고 셋 다 적중 0건이다. 관문은 **테넌트가 올린 소스**에 우리 빌드 박스에서
460
+ * 돌고, `readSafe` 상한(8MB) 안쪽이며 zip 의 파일 수에는 상한이 없다. 재현은
461
+ * `src/validate-arbitrary-class.test.ts`.
462
+ *
463
+ * 접두·이름 상한은 실물 표본에서 골랐다 — `zalkera-storefront` 워크트리 전체(파일 624)에
464
+ * 이 정규식을 돌리면 **임의값 토큰 46건**, 접두 **0겹** · 유틸리티 이름 최대 **9자** ·
465
+ * 값 최대 **35자**다. ⚠ 여기서 「토큰」은 S4 적중이 아니다 — 같은 표본의 **S4 적중은 0건**이다
466
+ * (실물에는 하드코딩 색이 없다. 검사기가 막아 왔기 때문이다). 재현은
467
+ * `src/validate-arbitrary-class.test.ts` KDoc 에 적었다.
468
+ *
469
+ * ⚠ **값 상한에는 「충분히 크다」가 없다.** `isColorLiteral` 은 값 **어디에든** 색 패턴이 있으면
470
+ * 참이고, CSS `background-image` 의 **레이어 목록에는 상한이 없다** — 콤마로 얼마든지 잇는다.
471
+ * 그래서 「문법이 낼 수 있는 가장 긴 색값」이라는 것은 존재하지 않고, 어떤 상한을 골라도 그보다
472
+ * 긴 정상 토큰을 만들 수 있다. 실측한 손실 시작점(메시 그라디언트, `--gate` 관통):
473
+ *
474
+ * 3겹 164자 → 잡음 · 4겹 219자 → 잡음 · **5겹 274자 → 놓침** · 6겹 329자 → 놓침
475
+ *
476
+ * 256 은 **4겹 메시까지 덮는 값**이지 안전 여유가 아니다. 값 상한을 다시 만질 때는 이 손실
477
+ * 시작점과 아래 비용 곡선을 **함께** 보라 — 400KB 악성 입력에서 128 → 35ms · 256 → 73ms ·
478
+ * 512 → 126ms · 1024 → 246ms · 상한 없음 → 19,324ms.
395
479
  */
396
- const ARBITRARY_CLASS = /(?:^|[^\w-])(?:[\w.-]+:)*!?(-?[a-z][a-zA-Z0-9-]*)-\[([^\]\s]*)\]/g;
480
+ const ARBITRARY_CLASS = /(?:^|[^\w-])(?:[\w.-]{1,32}:){0,8}!?(-?[a-z][a-zA-Z0-9-]{0,63})-\[([^\]\s]{0,256})\]/g;
397
481
  /** 임의 **속성** 문법 `[color:#f00]`·`[background-color:red]` — 유틸리티 접두 없이 색을 박는 우회로. */
398
- const ARBITRARY_COLOR_PROPERTY = /(?:^|[^\w-])(?:[\w.-]+:)*\[([a-zA-Z-]*color)\s*:\s*([^\]\s]*)\]/g;
482
+ const ARBITRARY_COLOR_PROPERTY = /(?:^|[^\w-])(?:[\w.-]{1,32}:){0,8}\[([a-zA-Z-]{0,64}color)\s*:\s*([^\]\s]{0,256})\]/g;
399
483
 
400
484
  /**
401
485
  * 이 임의값이 **색 리터럴**인가. `var(…)`·`theme(…)` 은 토큰 경유라 규약이 권하는 길이고 면제다
@@ -432,26 +516,51 @@ function hardcodedColorClasses(text) {
432
516
  *
433
517
  * 이 축이 **사실 판정**인 이유: `NEXT_PUBLIC_` 은 취향이 아니라 Next 의 계약이다 — 그 접두가 붙은
434
518
  * 환경변수는 빌드 시 값이 클라이언트 번들에 **문자 그대로 치환**된다. 그래서 "노출될 수 있다"가 아니라
435
- * **이미 노출됐다**. 오탐 여지가 없으므로 X 축(이름을 재는 교차오리진 가드)과 달리 `servingSink` 다.
519
+ * **이미 노출됐다**. 리터럴도 같다 소스는 zip 으로 통째 유통되므로 박힌 바이트가 곧 유출이다.
520
+ * 둘 다 판정이 형태 하나로 끝나 오탐 여지가 좁으므로, X 축(이름을 재는 교차오리진 가드)과 달리
521
+ * `servingSink` 다. 잣대마다 **재는 입력이 다르다** — [secretExposures] 가 그 이유를 적는다.
436
522
  *
437
523
  * ⚠ **못 재는 것**: 이름이 시크릿임을 안 밝힌 값(`NEXT_PUBLIC_FOO=oqsk_…`)은 이름만으로는 못 가른다.
438
524
  * 그래서 **값 쪽 잣대**를 하나 더 둔다 — 소스에 박힌 `oqsk_` 리터럴. 둘 다 피한 유출은 못 잡는다.
525
+ * `.env*` 의 **값**도 안 본다 — `.env` 는 이 키가 있어야 할 **제자리**이고(거기 있는 것이 정답이다),
526
+ * `.env.example` 의 자리표시자(`oqsk_…` 꼴)는 진짜 키와 형태로 안 갈린다. 값을 읽어
527
+ * 출력에 실으면 그것이 또 하나의 유출이기도 하다.
439
528
  */
440
529
  /** 서버 전용임이 이름에 드러난 환경변수에 `NEXT_PUBLIC_` 이 붙은 형태. */
441
530
  const PUBLIC_SECRET_ENV = /\bNEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|PRIVATE_KEY|STOREFRONT_KEY)[A-Z0-9_]*\b/g;
442
- /** 스토어프론트 서버 시크릿 키 리터럴(`oqsk_…`). 소스에 박히면 그 자체로 유출이다. */
531
+ /**
532
+ * 스토어프론트 서버 시크릿 키 리터럴(`oqsk_…`). 소스에 박히면 그 자체로 유출이다.
533
+ *
534
+ * ⚠ **이 파일 자신의 주석에 8자 이상 예시를 쓰면 안 된다.** 이 패키지를 `vendor/` 로 반입한 레포는
535
+ * 우리 배송 파일까지 훑으므로, 예시 한 줄이 그 레포의 관문을 rc=1 로 세운다(실측). 게다가 처방이
536
+ * 「즉시 재발급하세요」라 **존재하지 않는 키의 재발급**을 시키는 거짓 처방이 된다. E3 는 설계상
537
+ * 면제 마커가 없어(옳은 결정) 참 사유로 빠져나갈 길도 없다.
538
+ *
539
+ * 시험 픽스처와 같은 규범을 쓴다 — 접두 뒤 **7자 이하**. 잣대가 `{8,}` 이라 구성상 안 걸린다.
540
+ */
443
541
  const STOREFRONT_KEY_LITERAL = /\boqsk_[A-Za-z0-9_-]{8,}/g;
444
542
 
445
- /** 이 텍스트에서 발견된 시크릿 노출 형태의 사람용 사유 문자열들. */
446
- function secretExposures(text) {
543
+ /**
544
+ * 이 파일에서 발견된 시크릿 노출 형태의 사람용 사유 문자열들.
545
+ *
546
+ * ⚠ **두 잣대의 입력이 다르다 — 재는 것이 다르기 때문이다.**
547
+ *
548
+ * · `raw`(원문) — **값** 잣대(`oqsk_` 리터럴). 이 축이 묻는 것은 *코드가 무엇을 하는가*가 아니라
549
+ * **그 바이트가 배송물에 남는가**다. 소스는 zip 으로 통째 유통되고 주석은 지워지지 않으므로,
550
+ * 주석 안의 살아 있는 키는 코드 안의 것과 **똑같이 유출된다**. 주석 지운 사본에서 재면 못 본다.
551
+ * · `code`(주석 제거) — **이름** 잣대(`NEXT_PUBLIC_*SECRET*`). 이 축의 피해는 Next 가 그 변수를
552
+ * **읽어서** 번들에 박는 데서 난다. 「NEXT_PUBLIC_ 을 붙이지 마세요」라고 적은 주석은 규약을
553
+ * 가르치는 문장이지 노출이 아니다. 원문에서 재면 우리 자신의 안내 주석이 관문 error 가 된다.
554
+ */
555
+ function secretExposures(code, raw) {
447
556
  const out = [];
448
- for (const name of new Set(text.match(PUBLIC_SECRET_ENV) ?? [])) {
557
+ for (const name of new Set(code.match(PUBLIC_SECRET_ENV) ?? [])) {
449
558
  out.push(
450
559
  `${name} — NEXT_PUBLIC_ 접두가 붙은 값은 Next 가 브라우저 번들에 그대로 박습니다. ` +
451
560
  `서버 전용 시크릿이면 접두를 떼고 서버(route handler·RSC)에서만 읽으세요.`,
452
561
  );
453
562
  }
454
- if (STOREFRONT_KEY_LITERAL.test(text)) {
563
+ if (STOREFRONT_KEY_LITERAL.test(raw)) {
455
564
  STOREFRONT_KEY_LITERAL.lastIndex = 0; // `g` 정규식의 상태를 다음 파일로 흘리지 않는다.
456
565
  out.push(
457
566
  `스토어프론트 서버 시크릿 키(oqsk_…)가 소스에 박혀 있습니다 — 소스는 zip 으로 유통되고 ` +
@@ -464,6 +573,15 @@ function secretExposures(text) {
464
573
  const errors = [];
465
574
  const warnings = [];
466
575
  const layoutFiles = [];
576
+ /** C1 그래프의 진입점. `layoutFiles` 와 짝이다 — 종전엔 page 축만 그래프가 없었다. */
577
+ const seoPageFiles = [];
578
+ /**
579
+ * 실제로 규칙을 돌린 소스 파일 수. **"잴 것이 아예 없었다"는 통과가 아니다.**
580
+ * 이 검사기는 "못 잰 것"(rc=7) 칸을 잘 만들어 두고도 "잴 것이 없었다" 칸이 없었다 — 빈 트리나
581
+ * 오타난 경로를 `--gate` 에 주면 `✅ 통과` rc=0 이었다(심의 실증). 관문은 서빙을 인증하는 자리라
582
+ * 무엇도 인증하지 않은 실행을 합격으로 세면 안 된다.
583
+ */
584
+ let inspectedCount = 0;
467
585
  const cssFiles = [];
468
586
  let singletonFound = false;
469
587
 
@@ -476,7 +594,14 @@ let singletonFound = false;
476
594
  * package.json 을 못 찾거나 파싱 실패하면 none(안전 — S 안 들이댄다).
477
595
  * 백엔드는 스택을 모른다 — 선언은 레포 안에 살고 validator 가 현장에서 읽는다.
478
596
  */
479
- /** 모르는 `zalkera.styling` 값. 모드는 `none` 이지만 조용히 넘어가지 않는다. */
597
+ /**
598
+ * 모르는 `zalkera.styling` 값. 모드는 `none` 이지만 조용히 넘어가지 않는다.
599
+ *
600
+ * ⚠ **경고로 끝내면 관문이 거짓 통과를 낸다.** 「선언을 안 했다」(BYO — 정상 형상)와 「선언은
601
+ * 했는데 우리가 못 알아본다」는 다른 사실이다. 뒤쪽은 규칙군 전체가 **안 돈** 것이므로 정확히
602
+ * 이 검사기의 「못 잰 것」 칸(rc=7)에 속한다 — W-CONTRACT·EPARSE·「0개 훑음」이 이미 그 칸을
603
+ * 쓴다. 값 하나 오타(`tailwind-token`)로 error 3건이 0건이 되고 `--gate` 가 rc=0 을 내던 자리다.
604
+ */
480
605
  let styleDeclarationNote = null;
481
606
 
482
607
  function detectStyleMode(srcDir) {
@@ -484,7 +609,7 @@ function detectStyleMode(srcDir) {
484
609
  for (let i = 0; i < 12; i++) {
485
610
  const pkgPath = join(dir, "package.json");
486
611
  try {
487
- if (statSync(pkgPath).isFile()) {
612
+ if (fileExists(pkgPath)) {
488
613
  // 못 읽으면 모드가 조용히 강등되어 **S 규칙군이 통째로 스킵**되고 ✅ rc 0 이 난다
489
614
  // (심의 실측). 선언을 안 한 것과 못 읽은 것은 다른 사실이다.
490
615
  const declText = readSafe(pkgPath, "스타일 규약 모드 판정(package.json)");
@@ -501,6 +626,12 @@ function detectStyleMode(srcDir) {
501
626
  // 오류 3건이 0건으로). 콘텐츠축은 같은 형상에 `[N0]` 을 내는데 스타일축에는 대응물이
502
627
  // 없었다 — "선언 안 함"과 "모르는 값"은 다른 사실이다.
503
628
  styleDeclarationNote = String(styling);
629
+ // 관문에서 rc=7 — 스킵을 통과로 세지 않는다(위 KDoc).
630
+ markUnmeasured(
631
+ pkgPath,
632
+ "EDECL",
633
+ `zalkera.styling ${JSON.stringify(String(styling))} 을 몰라 S 규칙군을 못 돌렸습니다`,
634
+ );
504
635
  return "none";
505
636
  }
506
637
  const deps = {...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {})};
@@ -697,14 +828,60 @@ function escapeRegExp(value) {
697
828
  }
698
829
 
699
830
  /** 이 텍스트가 첫 엔트리를 채택하는가. 직접 표현식 → 변수 경유 순으로 본다. 걸린 형상 문자열을 준다(없으면 null). */
831
+ /** 채택 형상(`.split(",")[0]` 계열)의 **모든** 출현. 한 번만 훑는다. */
832
+ // ⚠ `i` 를 붙이지 않는다 — 종전 판정(`viaVariable`)은 플래그가 없었다. 붙이면 `list.SPLIT(",")[0]`
833
+ // 같은, JS 에 없는 철자를 새로 문다(오탐 방향). `g` 는 `matchAll` 이 요구한다.
834
+ const ADOPTION_SHAPE = new RegExp(`${SPLIT_ON_COMMA}${FIRST_ENTRY}`, "g");
835
+
836
+ /** 변수 이름과 채택 지점 사이에 허용하는 거리(원래 규칙 그대로). */
837
+ const I1_GAP = 40;
838
+
700
839
  function firstHopAdoption(text) {
701
840
  if (!XFF_NAME.test(text)) return null; // 헤더를 아예 안 읽으면 이 축의 대상이 아니다(대다수 파일이 여기서 끝난다).
702
841
  if (I1_DIRECT.test(text)) return "x-forwarded-for 를 읽어 바로 첫 엔트리를 채택";
842
+
843
+ // ⚠ **바인딩마다 전문을 다시 훑지 않는다.** 종전에는 바인딩 하나당 `new RegExp(...).test(text)` 를
844
+ // 불러 O(바인딩수 × 파일크기)였다 — 바인딩 수가 파일 크기에 비례하는 입력에서 2차가 된다.
845
+ // 실측(관문 관통, 한 파일): 256KB 810ms · 512KB 1,510ms · 1MB 4,914ms(배증당 ×3 이상).
846
+ // `readSafe` 상한(8MB) **안쪽**이라 크기 가드에 안 걸리고, zip 의 파일 수에는 상한이 없다.
847
+ // 재현: `const v0 = req.headers["x-forwarded-for"];` 줄을 목표 크기까지 반복.
848
+ //
849
+ // 판정은 바꾸지 않는다 — 원래 규칙은 「이름이 나오고, 40자 안에 채택 형상이 온다」이다.
850
+ // 그러니 **채택 형상을 한 번 훑고**, 각 지점 앞의 그 창에서만 이름을 찾는다.
703
851
  I1_BINDING.lastIndex = 0;
704
- for (const m of text.matchAll(I1_BINDING)) {
705
- const name = escapeRegExp(m[1]);
706
- const viaVariable = new RegExp(`\\b${name}\\b[\\s\\S]{0,40}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`);
707
- if (viaVariable.test(text)) return `\`${m[1]}\`(x-forwarded-for ) 엔트리를 채택`;
852
+ const names = [...text.matchAll(I1_BINDING)].map((m) => m[1]);
853
+ if (names.length === 0) return null;
854
+
855
+ // ⚠ **창을 뜨지 않는다.** 종전에는 채택 지점마다 `text.slice(at - 40 - longest, at)` 떠서
856
+ // 그 안을 다시 훑었다. `longest` 는 **테넌트 소스의 식별자 길이**라 상한이 없어, 비용이
857
+ // `채택형상 수 × 식별자 길이` 로 다시 초선형이 됐다. 실측(관문 관통, `./src --gate`):
858
+ // 식별자 30,000자 한 파일 — 205KB 1,316ms · 908KB 5,767ms · 7.0MB 46,002ms.
859
+ // `readSafe` 상한(8MB) 안쪽이고 zip 의 파일 수에는 상한이 없다.
860
+ //
861
+ // 양쪽 위치를 **각각 한 번씩** 모아 두 포인터로 맞춘다. 술어는 그대로다 —
862
+ // 「이름 끝이 채택 지점 앞 `I1_GAP` 자 안」.
863
+ //
864
+ // ⚠ **긴 이름을 먼저 적는다.** JS 교대는 먼저 적힌 대안을 잡는데 `$` 는 `\w` 가 아니라 `a$b`
865
+ // 의 시작 자리에 `\b` 가 선다 — `a` 가 먼저 걸리면 끝 위치가 2자 앞당겨져 40자 경계에서
866
+ // 판정이 갈렸다(실측: 간격 35·36 에서 HIT 이 null 로 뒤집힘).
867
+ const sorted = [...names].sort((a, b) => b.length - a.length);
868
+ const anyName = new RegExp(`\\b(?:${sorted.map(escapeRegExp).join("|")})\\b`, "g");
869
+
870
+ // `matchAll` 은 왼→오른쪽이라 두 배열 모두 이미 오름차순이다.
871
+ const seen = [];
872
+ for (const found of text.matchAll(anyName)) {
873
+ seen.push({end: (found.index ?? 0) + found[0].length, name: found[0]});
874
+ }
875
+ if (seen.length === 0) return null;
876
+
877
+ let i = 0;
878
+ ADOPTION_SHAPE.lastIndex = 0;
879
+ for (const hit of text.matchAll(ADOPTION_SHAPE)) {
880
+ const at = hit.index ?? 0;
881
+ while (i < seen.length && seen[i].end <= at) i += 1; // `at` 이하인 마지막 끝을 남긴다
882
+ if (i > 0 && at - seen[i - 1].end <= I1_GAP) {
883
+ return `\`${seen[i - 1].name}\`(x-forwarded-for 값)의 첫 엔트리를 채택`;
884
+ }
708
885
  }
709
886
  return null;
710
887
  }
@@ -826,8 +1003,185 @@ const I2_METHODS = [
826
1003
  * 그래서 절에 개행을 허용하되 **따옴표·세미콜론은 금지**한다 — 절에는 원래 둘 다 안 나오고,
827
1004
  * 뒤 문장으로 폭주하려면 반드시 다음 import 의 모듈 문자열(따옴표)을 지나야 하기 때문이다.
828
1005
  */
829
- const I2_IMPORT_STATEMENT =
830
- /^[ \t]*import\s+(?!type\s)([^;"']*?)from\s*["'](?:@(?:zalkera|oneq(?:ue?)?)\/client|[^"']*lib\/(?:zalkera|oneq(?:ue?)?)(?:\/[^"']*)?)["']/gm;
1006
+ /**
1007
+ * 서버 클라이언트를 가리키는 **모듈 지정자**. 패키지(구 브랜드 포함)와 싱글턴 딥경로를 함께 본다.
1008
+ * 세 규칙(E1·E2·I2)이 **같은 문자열을 각자 다시 쓰던 것**이 이 파일의 반복 결함이었다 — 한 곳에 둔다.
1009
+ */
1010
+ const CLIENT_SPECIFIER = String.raw`(?:@(?:zalkera|oneq(?:ue?)?)\/client|[^"']*lib\/(?:zalkera|oneq(?:ue?)?)(?:\/[^"']*)?)`;
1011
+
1012
+ /**
1013
+ * `import`/`export … from "…"` 문장을 **TypeScript 파서로** 읽는다.
1014
+ *
1015
+ * ■ 왜 손으로 안 쓰나 — 다섯 판을 잃고 배운 것
1016
+ * 이 판정("이 파일이 클라이언트를 런타임 값으로 들여오는가")은 **바인딩 질문**이라 파서만
1017
+ * 답한다. 그런데 다섯 판 동안 정규식·창 스캐너·손수 만든 토크나이저로 근사했고, 매 판의 차단이
1018
+ * **직전 판의 수정이 만든 것**이었다:
1019
+ *
1020
+ * 정규식 `[^;]*?` → 세미콜론 없는 파일에서 O(N²)
1021
+ * `[^;\n]` 으로 좁힘 → 접힌 절을 못 봄
1022
+ * 창 + 길이 상한 → 그 상한이 그대로 우회로
1023
+ * 손수 만든 토크나이저 → 정규식 리터럴 안의 백틱이 파일 나머지를 삼킴
1024
+ *
1025
+ * 매번 언어 문법의 한 조각을 더 근사하다가 다음 조각에서 뚫렸다. 근사를 그만둔다.
1026
+ *
1027
+ * ■ 의존은 늘지 않는다
1028
+ * **검사 대상 레포의** TypeScript 를 쓴다(`@zalkera/client` 계약을 읽는 자리와 같은 관례).
1029
+ * 우리 팩은 `typescript` 를 devDependency 로 이미 물고, Next.js + TS 레포에는 언제나 있다.
1030
+ * 없으면 **못 잰 것으로 적는다** — 두 번째 구현을 두지 않는다. 그것이 이 병의 뿌리였다.
1031
+ *
1032
+ * @param src 원본 소스. 주석 제거도 필요 없다 — 파서가 주석을 안 센다.
1033
+ * @param where 파일 경로(파서가 TSX 판별에 쓴다). 없으면 TSX 로 읽는다(더 관대한 쪽).
1034
+ * @returns `{statements, reliable}`. `reliable` 이 false 면 **판독하지 못한 것**이지 «없는» 것이 아니다.
1035
+ */
1036
+ function fromStatements(src, where) {
1037
+ // ⚠ **자바스크립트 계열만 파서에 넘긴다.** `.json`·`.css` 를 넘기면 구문 오류가 나고, 그것을
1038
+ // «판독 실패» 로 읽으면 정상 트리가 통째로 빨개진다(실측: `content/nav.json`).
1039
+ // 모듈 문장이 있을 수 없는 파일은 **문장 0개를 읽은 것**이지 못 읽은 것이 아니다.
1040
+ if (where !== undefined && !/\.[cm]?[jt]sx?$/.test(where)) return {statements: [], reliable: true};
1041
+
1042
+ const ts = typescriptModule();
1043
+ if (ts === null) return {statements: [], reliable: false};
1044
+
1045
+ const tsx = where === undefined || /\.(tsx|jsx|js)$/.test(where);
1046
+ const file = ts.createSourceFile(
1047
+ where ?? "input.tsx",
1048
+ src,
1049
+ ts.ScriptTarget.Latest,
1050
+ /* setParentNodes */ false,
1051
+ tsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
1052
+ );
1053
+ // 구문 오류가 있으면 트리가 어디까지 맞는지 알 수 없다 — 그 상태의 «못 찾음» 은 사실이 아니다.
1054
+ if ((file.parseDiagnostics ?? []).length > 0) return {statements: [], reliable: false};
1055
+
1056
+ const out = [];
1057
+ for (const stmt of file.statements) {
1058
+ const read = readModuleStatement(ts, stmt);
1059
+ if (read !== null) out.push(read);
1060
+ }
1061
+ return {statements: out, reliable: true};
1062
+ }
1063
+
1064
+ /**
1065
+ * 최상위 문장 하나에서 `{kw, spec, bindsValue}` 를 뽑는다. 모듈 지정자가 없으면 `null`.
1066
+ *
1067
+ * **타입 여부를 문자열이 아니라 트리에서 읽는다.** 선행형(`import type {A}`)과 인라인형
1068
+ * (`import {type A, getX}`)이 문법적으로 다른 노드라, 문면으로 가르던 시절의 오탐·미탐이 사라진다.
1069
+ */
1070
+ function readModuleStatement(ts, stmt) {
1071
+ if (ts.isImportDeclaration(stmt)) {
1072
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) return null;
1073
+ return {
1074
+ kw: "import",
1075
+ spec: stmt.moduleSpecifier.text,
1076
+ bindsValue: importBindsValue(ts, stmt.importClause),
1077
+ locals: importLocals(ts, stmt.importClause),
1078
+ };
1079
+ }
1080
+ if (ts.isExportDeclaration(stmt)) {
1081
+ if (stmt.moduleSpecifier === undefined || !ts.isStringLiteral(stmt.moduleSpecifier)) return null;
1082
+ return {kw: "export", spec: stmt.moduleSpecifier.text, bindsValue: exportBindsValue(ts, stmt)};
1083
+ }
1084
+ return null;
1085
+ }
1086
+
1087
+ /**
1088
+ * 이 import 절이 묶는 **지역 이름들** — `[{local, imported}]`.
1089
+ *
1090
+ * `imported` 는 모듈이 내보낸 원래 이름이다(`default` · `*` · 명명 export). 별칭
1091
+ * (`import {headers as h}`)에서 둘이 갈리므로 둘 다 필요하다 — 호출은 `local` 로 하고
1092
+ * 판정은 `imported` 로 한다.
1093
+ */
1094
+ function importLocals(ts, clause) {
1095
+ if (clause === undefined || clause.isTypeOnly) return [];
1096
+ const out = [];
1097
+ if (clause.name !== undefined) out.push({local: clause.name.text, imported: "default"});
1098
+ const bindings = clause.namedBindings;
1099
+ if (bindings === undefined) return out;
1100
+ if (ts.isNamespaceImport(bindings)) return [...out, {local: bindings.name.text, imported: "*"}];
1101
+ for (const el of bindings.elements) {
1102
+ if (el.isTypeOnly) continue; // 타입은 런타임에 사라진다
1103
+ out.push({local: el.name.text, imported: (el.propertyName ?? el.name).text});
1104
+ }
1105
+ return out;
1106
+ }
1107
+
1108
+ /** `import` 가 런타임 값을 묶는가. 절이 아예 없으면 부수효과 전용(`import "x"`)이라 값이 아니다. */
1109
+ function importBindsValue(ts, clause) {
1110
+ if (clause === undefined) return false; // `import "@zalkera/client"` — 바인딩 0
1111
+ if (clause.isTypeOnly) return false; // `import type {A} from …`
1112
+ if (clause.name !== undefined) return true; // 기본 import
1113
+ const bindings = clause.namedBindings;
1114
+ if (bindings === undefined) return false;
1115
+ if (ts.isNamespaceImport(bindings)) return true; // `import * as ns from …`
1116
+ // ⚠ **빈 중괄호는 값이다.** `import {} from "x"` 는 이름을 하나도 안 묶지만 **모듈을 평가한다** —
1117
+ // 그 모듈이 클라이언트 번들에 들어간다. 기존 계약이 이것을 값으로 치고 시험이 고정한다.
1118
+ if (bindings.elements.length === 0) return true;
1119
+ return bindings.elements.some((el) => !el.isTypeOnly);
1120
+ }
1121
+
1122
+ /** 재수출이 런타임 값을 묶는가. `export * from` 은 타입만 골라낼 방법이 없어 값으로 친다. */
1123
+ function exportBindsValue(ts, stmt) {
1124
+ if (stmt.isTypeOnly) return false; // `export type {A} from …`
1125
+ const clause = stmt.exportClause;
1126
+ if (clause === undefined) return true; // `export * from …`
1127
+ if (ts.isNamespaceExport(clause)) return true; // `export * as ns from …`
1128
+ // 빈 재수출(`export {} from "x"`)도 모듈을 평가한다 — import 쪽과 같은 이유다.
1129
+ if (clause.elements.length === 0) return true;
1130
+ return clause.elements.some((el) => !el.isTypeOnly);
1131
+ }
1132
+
1133
+ /**
1134
+ * 검사 대상 레포의 TypeScript. 못 찾으면 `null` — 부르는 쪽이 «못 잰 것» 으로 적는다.
1135
+ *
1136
+ * ⚠ **검사 대상 트리에서 찾지 않는다.** `require()` 는 해석이 아니라 **평가**다 — 대상 트리의
1137
+ * `node_modules/typescript` 를 부르면 그 자리에 놓인 코드가 우리 프로세스에서 돈다. `--gate` 는
1138
+ * **테넌트가 올린 소스**에 우리 빌드 박스에서 도니, 그것은 곧 임의 코드 실행이다.
1139
+ *
1140
+ * 더 나쁜 것은 **조용하다는 점**이다: 가짜 모듈이 `statements: []` 를 돌려주면 들여오기 규칙
1141
+ * 전부가 「위반 없음·측정됨」으로 보고되고 `unmeasured` 도 안 뜬다. 관문이 초록을 찍으면서 눈이 먼다.
1142
+ *
1143
+ * 그래서 **이 검사기 자신의 자리에서만** 찾는다. 고객이 자기 레포에서 `npx zalkera-validate` 로
1144
+ * 부르면 이 파일은 그 레포의 `node_modules/@zalkera/client/bin/` 에 있으므로 거기서 위로 올라가
1145
+ * 같은 레포의 typescript 를 찾는다 — 자기 코드를 자기 도구로 재는 것이라 문제가 없다.
1146
+ * 우리가 남의 소스를 잴 때는 **우리 사본**을 쓰므로 대상 트리에 안 닿는다.
1147
+ *
1148
+ * 한 번 찾은 결과는 재사용한다 — 파일마다 resolve 하면 수천 번이다.
1149
+ */
1150
+ let typescriptCache;
1151
+ function typescriptModule() {
1152
+ if (typescriptCache !== undefined) return typescriptCache;
1153
+ try {
1154
+ typescriptCache = createRequire(import.meta.url)("typescript");
1155
+ } catch {
1156
+ typescriptCache = null;
1157
+ }
1158
+ return typescriptCache;
1159
+ }
1160
+
1161
+ /** 지정자가 클라이언트 계약을 가리키는가. 종전에는 갈래별 정규식이 각자 들고 있었다. */
1162
+ const CLIENT_SPECIFIER_EXACT = new RegExp(String.raw`^${CLIENT_SPECIFIER}$`);
1163
+
1164
+ /**
1165
+ * `import ... from` 이 아닌 **다른 들여오기 형태**. 셋 다 실제로 빌드·동작한다.
1166
+ *
1167
+ * ⑴ 동적 import — `await import("@zalkera/client")` · `import("…").then(…)`
1168
+ * ⑵ 재수출 — `export {createZalkeraClient} from "@zalkera/client"` · `export * from …`
1169
+ * ⑶ CJS — `require("@zalkera/client")`
1170
+ *
1171
+ * ⚠ **문자군이 개행을 넘지 못하게 한다**(`[^;\n]`). 이 세 형태는 한 줄 안에서 끝나므로 개행을
1172
+ * 넘을 이유가 없고, `[^;]` 로 넓히면 세미콜론 없는 파일에서 파일 끝까지 전방 스캔해 O(N²) 가
1173
+ * 된다. 문장형 import·재수출은 접힌 절을 봐야 해서 사정이 다르고, 그쪽은 정규식이 아니라
1174
+ * [fromStatements] 가 든다.
1175
+ *
1176
+ * 종전에는 E1 이 `^import ... from` 한 줄짜리 정규식이라 셋 다 **관문을 통과**했다(심의 실증:
1177
+ * 동적 import + 별칭, 재수출 둘 다 `rc=0`. 대조군인 평범한 static import 만 `rc=1`).
1178
+ * 값 바인딩 여부를 따질 필요가 없다 — 이 셋에는 `import type` 에 해당하는 형태가 없다.
1179
+ */
1180
+ const CLIENT_NON_STATEMENT_IMPORT = new RegExp(
1181
+ String.raw`(?:\bimport\s*\(\s*["']${CLIENT_SPECIFIER}["']` +
1182
+ String.raw`|\brequire\s*\(\s*["']${CLIENT_SPECIFIER}["'])`,
1183
+ "m",
1184
+ );
831
1185
 
832
1186
  /**
833
1187
  * 이 파일이 클라이언트를 **런타임 값으로** 들여오는가.
@@ -840,22 +1194,44 @@ const I2_IMPORT_STATEMENT =
840
1194
  * 구 브랜드(`@oneque/client`·`lib/oneque`)와 싱글턴 딥경로(`lib/zalkera/server`)도 같이 본다 —
841
1195
  * E1·E2 는 이미 둘 다 보는데 I2 만 빠져 있었다(심의 관찰).
842
1196
  */
843
- function importsClientAtRuntime(text) {
844
- I2_IMPORT_STATEMENT.lastIndex = 0;
845
- for (const m of text.matchAll(I2_IMPORT_STATEMENT)) {
846
- const clause = m[1];
847
- const braces = clause.match(/\{([^}]*)\}/);
848
- if (!braces) return true; // 기본·네임스페이스·부수효과 import — 값이다
849
- const outside = clause.replace(/\{[^}]*\}/, "").replace(/[\s,]/g, "");
850
- if (outside.length > 0) return true; // `import z, {type Order} from …`
851
- const specifiers = braces[1]
852
- .split(",")
853
- .map((x) => x.trim())
854
- .filter(Boolean);
855
- if (specifiers.length === 0) return true; // `import {} from …` — 부수효과
856
- if (specifiers.some((x) => !/^type\s/.test(x))) return true;
1197
+ function importsClientAtRuntime(text, where) {
1198
+ // **두 팔의 입력이 다르다.**
1199
+ //
1200
+ // 정적 `import`/재수출은 **파서**가 본다 — 파서는 주석을 알아서 건너뛰므로 원문을 줘야 한다
1201
+ // (주석 제거는 URL 안의 `//` 를 먹어 문자열을 깨뜨린다).
1202
+ //
1203
+ // 동적 `import()`·`require()` 는 아직 **정규식**이 본다 — 정규식은 주석을 모르므로 지운
1204
+ // 사본을 줘야 한다. 원문을 주면 양쪽으로 틀린다(실측): 주석 처리된 `// await import("…")`
1205
+ // 한 줄이 E1 로 관문을 막고(E1 에는 면제 마커가 없어 탈출구가 없다), 반대로
1206
+ // `await import(/* c */ "")` 는 통과한다.
1207
+ //
1208
+ // 한 인자를 두 팔이 나눠 쓰던 것이 이 결함의 형상이다. 각자에게 맞는 사본을 준다.
1209
+ if (CLIENT_NON_STATEMENT_IMPORT.test(stripComments(text))) return true;
1210
+ // 문장형 `import`·재수출은 **같은 판독기**를 쓴다. 갈래마다 정규식을 따로 쓰던 것이 이 파일의
1211
+ // 반복 결함이었다 — 인라인 타입 재수출 오탐도, 접힌 값 재수출 누락도 거기서 나왔다.
1212
+ // 타입 여부는 문면이 아니라 **트리에서** 읽는다(`bindsValue`).
1213
+ const read = fromStatements(text, where);
1214
+ for (const {spec, bindsValue} of read.statements) {
1215
+ if (!CLIENT_SPECIFIER_EXACT.test(spec)) continue;
1216
+ if (bindsValue) return true;
857
1217
  }
858
- return false;
1218
+ // ⚠ **못 읽었으면 «안 쓴다»가 아니다.** 파서가 구문 오류를 봤거나 TypeScript 를 못 찾았다는
1219
+ // 것은 우리가 **판독하지 못했다**는 뜻이고, 그 상태의 «못 찾음» 은 사실이 아니라 무지다.
1220
+ // 이 자리에서 무지를 통과로 읽으면 관문이 조용히 눈이 먼다.
1221
+ if (!read.reliable) markUnreadable(where, "들여오기 판독(E1·E2·I2)");
1222
+ return !read.reliable;
1223
+ }
1224
+
1225
+ /**
1226
+ * 판독이 어긋난 소스를 **못 잰 자리로 적는다.** 조용히 넘기면 «못 찾음» 과 «없음» 이 같은 출력이
1227
+ * 된다 — 이 검사기가 여러 번 겪은 형상이다. 경로를 모르면 그 사실까지 적는다.
1228
+ */
1229
+ function markUnreadable(where, what) {
1230
+ markUnmeasured(
1231
+ where ?? "(경로 미상)",
1232
+ "EPARSE",
1233
+ `${what} — 소스를 파싱하지 못했습니다(구문 오류이거나 TypeScript 를 못 찾았습니다)`,
1234
+ );
859
1235
  }
860
1236
 
861
1237
  /**
@@ -1219,7 +1595,7 @@ function callArguments(source, code, fn) {
1219
1595
  * 그래서 파일이 `clientIp` 를 담은 변수를 만들면 그 파일은 통과시킨다 — **오탐보다 미탐을 고르는 선택**이다.
1220
1596
  * 이 규칙은 경고이고, 거짓 양성은 거래처 CI 를 노랗게 만들지만 거짓 음성은 우리가 못 잡을 뿐이다.
1221
1597
  */
1222
- function missingClientIp(rawWithBom, jsx = false) {
1598
+ function missingClientIp(rawWithBom, jsx = false, where) {
1223
1599
  // ⚠ **BOM 을 지운다.** 1행이 client import 인 BOM 파일은 `^[ \t]*import` 앵커에 안 걸려 **그 파일의
1224
1600
  // I2 가 통째로 스킵**됐다(심의 실측). 이미 두 번 닫은 "파일 통째 스킵"(접힌 import·블록 주석)과
1225
1601
  // 같은 계열이다. 길이를 지키려고 지우지 않고 **공백으로 바꾼다** — 인자 슬라이스의 오프셋 때문이다.
@@ -1228,7 +1604,8 @@ function missingClientIp(rawWithBom, jsx = false) {
1228
1604
  // 고객 레포와 서빙 빌드에서도 돈다.
1229
1605
  if (!/@(?:zalkera|oneq(?:ue?)?)\/client|lib\/(?:zalkera|oneq(?:ue?)?)/.test(raw)) return null;
1230
1606
  const {masked: code, withStrings} = maskCode(raw, jsx);
1231
- if (!importsClientAtRuntime(withStrings)) return null;
1607
+ // 파서에는 **원문**을 넘긴다(덮은 사본은 문자열을 지워 지정자가 사라진다).
1608
+ if (!importsClientAtRuntime(raw, where)) return null;
1232
1609
  // `clientIp` 를 담은 **객체 변수의 이름들**을 모은다(`const access = {phone, context: {clientIp}}`).
1233
1610
  //
1234
1611
  // ⚠ **이름을 모으는 이유.** 종전엔 그런 변수가 하나라도 있으면 **파일 전체**를 통과시켰다. 그래서
@@ -1350,7 +1727,7 @@ function detectContentMode(srcDir) {
1350
1727
  for (let i = 0; i < 12; i++) {
1351
1728
  const pkgPath = join(dir, "package.json");
1352
1729
  try {
1353
- if (statSync(pkgPath).isFile()) {
1730
+ if (fileExists(pkgPath)) {
1354
1731
  // 위와 같은 자리 — 못 읽으면 N 규칙군이 통째로 스킵된다.
1355
1732
  const declText = readSafe(pkgPath, "콘텐츠 규약 모드 판정(package.json)");
1356
1733
  if (declText === null) break;
@@ -1373,6 +1750,11 @@ function detectContentMode(srcDir) {
1373
1750
  '사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `"source"` 로 바꾸세요 ' +
1374
1751
  "(llms.txt §9.1).",
1375
1752
  );
1753
+ markUnmeasured(
1754
+ join(repoRoot, "package.json"),
1755
+ "EDECL",
1756
+ 'zalkera.content 가 은퇴값 "sections-db" 라 N 규칙군을 못 돌렸습니다',
1757
+ );
1376
1758
  return "none";
1377
1759
  }
1378
1760
  if (declared !== undefined) {
@@ -1390,6 +1772,11 @@ function detectContentMode(srcDir) {
1390
1772
  `이 경고는 무시하십시오.`
1391
1773
  : `. 일부러 다른 값을 쓰신 것이면 이 경고는 무시하십시오.`),
1392
1774
  );
1775
+ markUnmeasured(
1776
+ join(repoRoot, "package.json"),
1777
+ "EDECL",
1778
+ `zalkera.content ${shown} 을 몰라 N 규칙군을 못 돌렸습니다`,
1779
+ );
1393
1780
  return "none";
1394
1781
  }
1395
1782
  return contentPageFiles(repoRoot).length > 0 ? "inferred" : "none";
@@ -1446,7 +1833,55 @@ function echoValue(value) {
1446
1833
  }
1447
1834
 
1448
1835
  const DOC_PATH_TOKEN = /^[\p{L}\p{N}_.\-/[\]]+\.(?:tsx?|jsx?|mjs|cjs|json|css|md)$/u;
1449
- const DOC_PATH_SKIP_PREFIX = ["doc/", "node_modules/", ".zalkera/", "@", "/", "http"];
1836
+ /**
1837
+ * 좌표로 **안 보는** 접두들.
1838
+ *
1839
+ * `@`·`/`·`http` 는 애초에 이 레포의 상대 좌표가 아니고, `doc/`·`.zalkera/` 는 이 검사기의 어휘다.
1840
+ * `.next/` 는 **검사기 자신이 처방하는 경로**다 — O1 이 「우리 박스는 `.next/standalone/server.js` 를
1841
+ * 실행합니다」라고 적으므로, 그 문장을 그대로 옮겨 적은 문서를 error 로 막으면 우리가 시킨 일을
1842
+ * 우리가 벌하는 셈이다. 생성물은 소스 트리에 **정의상 없다.**
1843
+ */
1844
+ const DOC_PATH_SKIP_PREFIX = ["doc/", "node_modules/", ".zalkera/", ".next/", "@", "/", "http"];
1845
+
1846
+ /*
1847
+ * ── 생성물 좌표 : 목록을 늘리지 않고 **레포의 선언을 읽는다** ────────────────────────
1848
+ *
1849
+ * D1 이 재는 명제는 「문서의 좌표가 실물을 가리키는가」인데, **빌드 산출물은 소스 트리에 정의상
1850
+ * 없다**. `dist/`·`out/`·`build/` 를 손으로 더 적는 것은 이 레포가 반복해 온 병(막을 것을 열거하다
1851
+ * 층마다 샌다)의 재발이다. 어느 디렉터리가 생성물인지는 **레포가 `.gitignore` 에 이미 적어 뒀다.**
1852
+ *
1853
+ * ⚠ **읽는 범위를 좁게 잡는다** — 평범한 디렉터리 항목만 본다(`node_modules` · `.next` ·
1854
+ * `dist-presets`). gitignore 전 문법을 흉내 내면 그 흉내가 새 결함면이 되고, D1 은 선언 레포에서
1855
+ * **error** 라 헛디디면 남의 빌드가 멈춘다. 못 읽으면 아무것도 안 건너뛴다 — 검사가
1856
+ * **약해지는 쪽**으로 실패하지 않는다.
1857
+ *
1858
+ * 글롭(`*.tsbuildinfo`)·부정(`!.env.example`) 걸러내기는 **접두 대조에서는 어차피 무해하다** —
1859
+ * `*.tsbuildinfo/` 로 시작하는 좌표는 없으니 남겨 둬도 아무것도 안 걸러진다(변이로 확인:
1860
+ * 그 두 줄을 지워도 시험이 안 빨개진다). 그래도 남기는 이유는 **읽는 범위를 선언**하기
1861
+ * 위해서다 — 대조를 글롭 인식으로 바꾸는 사람이 여기서 멈춘다.
1862
+ *
1863
+ * ⚠ **레포가 `src` 를 무시한다고 적으면 그 아래 좌표는 안 재진다.** 그것은 그 레포의 선언이고
1864
+ * 우리가 뒤집을 자리가 아니다 — 다만 이 함수가 그런 힘을 가진다는 사실은 적어 둔다.
1865
+ *
1866
+ * ⚠ **팩 zip 에는 `.gitignore` 가 없다.** 그래서 `.next/` 는 위 상수에 바닥으로 남는다.
1867
+ */
1868
+ let ignoredPrefixCache;
1869
+
1870
+ function ignoredDirPrefixes() {
1871
+ if (ignoredPrefixCache !== undefined) return ignoredPrefixCache;
1872
+ const text = readSafe(join(repoRootDir, ".gitignore"), "D1 생성물 좌표 판독(.gitignore)");
1873
+ if (text === null) return (ignoredPrefixCache = []);
1874
+ const out = [];
1875
+ for (const line of text.split("\n")) {
1876
+ const entry = line.trim();
1877
+ if (entry === "" || entry.startsWith("#") || entry.startsWith("!")) continue;
1878
+ if (/[*?[\]]/.test(entry)) continue; // 글롭은 안 흉내 낸다
1879
+ const cleaned = entry.replace(/^\/+/, "").replace(/\/+$/, "");
1880
+ if (cleaned === "" || cleaned.split("/").includes("..")) continue;
1881
+ out.push(`${cleaned}/`);
1882
+ }
1883
+ return (ignoredPrefixCache = out);
1884
+ }
1450
1885
 
1451
1886
  /** 문서 본문에서 이 레포의 파일을 가리키는 것으로 판정되는 백틱 토큰. */
1452
1887
  /**
@@ -1473,15 +1908,49 @@ function docPathTokens(text, only) {
1473
1908
  if (!tok.includes("/")) continue; // 위 KDoc 의 실측 — 이름만 있는 토큰은 좌표가 아니다
1474
1909
  if (!DOC_PATH_TOKEN.test(tok)) continue;
1475
1910
  if (DOC_PATH_SKIP_PREFIX.some((p) => tok.startsWith(p))) continue;
1911
+ // 레포가 스스로 「생성물」이라고 적은 곳 — 소스 트리에 없는 것이 정상이다.
1912
+ if (ignoredDirPrefixes().some((p) => tok.startsWith(p))) continue;
1476
1913
  if (only && !only.test(tok)) continue;
1477
1914
  seen.add(tok);
1478
1915
  }
1479
1916
  return [...seen].sort();
1480
1917
  }
1481
1918
 
1919
+ /**
1920
+ * **레포 안의 실제 파일인가.**
1921
+ *
1922
+ * ⚠ 맨몸 `statSync(p).isFile()` 로 물으면 **심링크 표적을 따라간다.** 신뢰 밖 소스가
1923
+ * `src/app/globals.css` 를 임의 절대경로로 심링크해 두면, 그 경로가 있느냐 없느냐로 검사기
1924
+ * 출력이 갈린다 — 레포 밖 파일의 존재 여부가 `--gate` 응답으로 새는 오라클이다(심의 지적).
1925
+ * 자매 [nameExists] 는 같은 이유로 이미 `lstatSync` 를 쓰는데 이 함수만 맨몸이었다.
1926
+ *
1927
+ * [containedPath] 로 먼저 봉쇄한다 — 성분 어디든 레포 밖을 가리키면 표적을 묻지 않고 `false` 다.
1928
+ * 그래서 "밖을 가리킴"과 "없음"이 **같은 답**이 되어 구별할 수 없다.
1929
+ *
1930
+ * **파일**의 존재를 묻는 자리는 이 함수를 지난다. 디렉터리·설정파일을 묻는 세 자리
1931
+ * (`app`/`pages` 디렉터리·`next.config.*`)는 [dirExists]·[configExists] 가 같은 봉쇄를 쓴다.
1932
+ *
1933
+ * ⚠ 상대경로로 불러도 되게 **먼저 절대경로로 편다.** `containedPath` 는 `resolve` 를 쓰므로
1934
+ * 상대경로면 CWD 기준이 되고, CWD 가 레포 루트가 아니면 정상 파일이 조용히 `false` 가 된다.
1935
+ */
1482
1936
  function fileExists(path) {
1937
+ const full = resolve(path);
1938
+ const contained = containedPath(dirname(full), basename(full));
1939
+ if (contained === null) return false;
1940
+ try {
1941
+ return statSync(contained).isFile();
1942
+ } catch {
1943
+ return false;
1944
+ }
1945
+ }
1946
+
1947
+ /** 디렉터리 존재. [fileExists] 와 같은 봉쇄를 지난다 — 고정 이름 디렉터리도 오라클이 된다. */
1948
+ function dirExists(path) {
1949
+ const full = resolve(path);
1950
+ const contained = containedPath(dirname(full), basename(full));
1951
+ if (contained === null) return false;
1483
1952
  try {
1484
- return statSync(path).isFile();
1953
+ return statSync(contained).isDirectory();
1485
1954
  } catch {
1486
1955
  return false;
1487
1956
  }
@@ -1690,6 +2159,41 @@ function walkTree(dir, onFile, what) {
1690
2159
  // 그 아래 것은 빌드에 실리므로 검사 대상이다 — 잣대가 "번들에 들어가는가"이지
1691
2160
  // "이름이 무엇인가"가 아니다. (`.git` 은 비용 때문에도 뺀다 — 심의 실측 +59%.)
1692
2161
  if (BUILD_DROPS.has(name) && dirname(full) === repoRootDir) continue;
2162
+ // ⚠ **심링크 봉쇄를 `statSync` 보다 먼저 한다.** `statSync` 는 심링크를 따라가므로 뒤에
2163
+ // 두면 대상 부재는 `ENOENT`, 대상 존재는 `EXDEV` 로 갈려 **존재 오라클이 남는다**
2164
+ // (수리 중 실측 — 내용 유출을 막고도 이 한 칸이 살아 있었다). 링크가 어디를 가리키든
2165
+ // 같은 문장으로 답해야 아무것도 새지 않는다.
2166
+ //
2167
+ // 종전에는 이 봉쇄가 **디렉터리 갈래에만** 있었다. 그 비대칭이 `check()` 의 맨몸 읽기와
2168
+ // 만나 레포 밖 임의 절대경로의 존재·가독성·내용 조각을 `--gate` 응답으로 돌려주는
2169
+ // 경로가 됐다(심의 실증). 규칙의 누락이지 설계의 선택이 아니었다.
2170
+ //
2171
+ // 레포 **안**을 가리키는 심링크는 그대로 통과시킨다 — 경계는 이탈이지 심링크 자체가
2172
+ // 아니다(`readSafe` 와 같은 문장). 끊어진 사슬은 실경로를 못 물으므로 링크 원문을
2173
+ // **어휘적으로** 풀어 판정한다.
2174
+ if (isSymlink(full)) {
2175
+ // ⚠ **`realpathSync` 가 던지면 어휘 폴백을 하지 않는다.** 첫 판은 `readlinkSync` 로
2176
+ // 링크 **원문 한 홉**을 풀어 판정했는데, 사슬이 두 홉이면 첫 홉이 레포 안이라
2177
+ // (`x.tsx → ./hop`) 관문을 통과하고, 뒤의 `statSync` 가 사슬을 끝까지 따라가
2178
+ // **말단 errno 를 그대로 회신**했다 — 레포 밖 임의 절대경로의 존재·권한이 새는
2179
+ // 오라클이다(재심의 실증: EXDEV/ENOENT/EACCES 로 갈림).
2180
+ //
2181
+ // 자매 `containedPath` 는 이미 옳다 — 성분에서 realpath 가 던지면 `null` 을 돌려
2182
+ // 균일하게 봉쇄하고 어휘 해석을 하지 않는다. 같은 규칙을 쓴다: **실경로를 못 물으면
2183
+ // 그 자체가 "판정 불가"이고, 판정 불가는 통과가 아니다.**
2184
+ //
2185
+ // 정당한 in-repo 심링크는 realpath 가 던지지 않으므로 그대로 통과한다(가용성 손실 0).
2186
+ let linkTarget = null;
2187
+ try {
2188
+ linkTarget = realpathSync(full);
2189
+ } catch {
2190
+ linkTarget = null;
2191
+ }
2192
+ if (linkTarget === null || !within(linkTarget, repoRootDir)) {
2193
+ markUnmeasured(full, "EXDEV", `레포 밖을 가리키는 심링크라 따라가지 않았습니다 — ${what}`);
2194
+ continue;
2195
+ }
2196
+ }
1693
2197
  let stat;
1694
2198
  try {
1695
2199
  stat = statSync(full);
@@ -1719,7 +2223,21 @@ function walkTree(dir, onFile, what) {
1719
2223
  continue;
1720
2224
  }
1721
2225
  // ⓐ 소스 루트 안 — 따라가지 않는다. 그 실디렉터리는 제 자리에서 훑는다.
1722
- if (within(real, root)) continue;
2226
+ //
2227
+ // ⚠ **다만 «어느 경로로» 훑었는지가 규칙을 가른다.** C1·N 계열은 `relative(root, file)`
2228
+ // 의 첫 조각이 `app`/`pages` 인지로 적용 여부를 정한다. `app` 자체가 심링크면 그
2229
+ // 실디렉터리는 다른 이름으로 훑여, 파일은 읽히는데 **라우트 규칙이 하나도 안 걸린다** —
2230
+ // 관문이 초록을 찍으면서 눈이 먼다(실측). 그 자리는 «못 잰 것»으로 적는다.
2231
+ if (within(real, root)) {
2232
+ if (isRouteRootPath(full)) {
2233
+ markUnmeasured(
2234
+ full,
2235
+ "ELINKDIR",
2236
+ `라우트 뿌리가 심링크라 경로 기반 규칙을 못 걸었습니다 — ${what}`,
2237
+ );
2238
+ }
2239
+ continue;
2240
+ }
1723
2241
  // ⓑ 레포 밖 — 따라가지 않고 적는다. 못 잰 것이지 통과가 아니다.
1724
2242
  if (!within(real, repoRootDir)) {
1725
2243
  markUnmeasured(full, "EXDEV", `레포 밖을 가리키는 심링크라 따라가지 않았습니다 — ${what}`);
@@ -1795,6 +2313,20 @@ function containedPath(from, spec) {
1795
2313
  return cur;
1796
2314
  }
1797
2315
 
2316
+ /**
2317
+ * 이 경로가 **라우트 뿌리 자체**인가 — `<소스루트>/app` 또는 `<소스루트>/pages`.
2318
+ *
2319
+ * C1·N 계열은 `relative(root, file)` 의 첫 조각이 `app`·`pages` 인지로 적용을 정한다. **그 뿌리가**
2320
+ * 심링크면 실디렉터리가 다른 이름으로 훑여 규칙이 통째로 안 걸린다.
2321
+ *
2322
+ * ⚠ **뿌리 «아래» 는 아니다.** `app/up -> ..` 같은 순환 차단용 심링크는 그 아래 파일이 제 자리에서
2323
+ * 정상으로 훑이므로 못 잰 것이 없다 — 거기까지 잡으면 정상 트리가 판정 불가가 된다(실측).
2324
+ */
2325
+ function isRouteRootPath(full) {
2326
+ const segments = relative(root, full).split(sep);
2327
+ return segments.length === 1 && (segments[0] === "app" || segments[0] === "pages");
2328
+ }
2329
+
1798
2330
  /** 심링크인가. `lstatSync` 는 따라가지 않는다. 못 물으면 아니라고 답한다(뒤의 `statSync` 가 잡는다). */
1799
2331
  function isSymlink(p) {
1800
2332
  try {
@@ -1829,6 +2361,7 @@ function walk(dir) {
1829
2361
  if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) {
1830
2362
  check(full);
1831
2363
  if (isLayoutFile(full)) layoutFiles.push(full);
2364
+ if (isSeoPageFile(full)) seoPageFiles.push(full);
1832
2365
  } else if (name.endsWith(".css")) {
1833
2366
  cssFiles.push(full);
1834
2367
  }
@@ -1876,12 +2409,97 @@ function isLayoutFile(file) {
1876
2409
  * 것과 같은 함정을 검출 쪽에 남겨뒀었다). 이 규칙들은 `export const dynamic =`·`cache:` 같은
1877
2410
  * 앞머리를 요구하므로 단순 메시지 문자열엔 걸리지 않는다.
1878
2411
  */
2412
+ /*
2413
+ * ── 동적 API 판정 : **이름이 아니라 어디서 왔는지를 본다** ──────────────────────────
2414
+ *
2415
+ * `cookies`·`headers`·`connection` 은 Next 의 전유물이 아니라 **평범한 이름**이다. 이름만 재던
2416
+ * 종전 표는 고객의 보통 헬퍼 하나로 관문 error 를 냈다(실측):
2417
+ *
2418
+ * export function headers(extra = {}) { return {"content-type": "application/json", ...extra}; }
2419
+ * export function connection() { return {query: …}; }
2420
+ * → ❌[C1] ×4 (직접 2 + 그래프 경유 2), rc=1
2421
+ *
2422
+ * 탈출구가 있긴 했다 — `// zalkera-allow-dynamic: <이유>`. 그런데 그 레포는 동적 SSR 을 쓰지
2423
+ * **않는다**. 즉 빠져나가려면 **사실이 아닌 사유**를 적어야 했다. 기계가 거짓말을 시키는 처방은
2424
+ * 처방이 아니다.
2425
+ *
2426
+ * 판정의 진짜 근거는 **바인딩의 출처**다: 이 호출이 `next/headers`(또는 `next/cache`)가 내보낸
2427
+ * 것인가. 그건 추측이 아니라 파일이 스스로 적어 둔 사실이고, 파서가 별칭까지 정확히 읽는다.
2428
+ *
2429
+ * ⚠ **미탐으로 기울지 않는다.** 파서가 파일을 못 읽거나(EPARSE), 모듈 이름이 소스에 보이는데
2430
+ * import 로는 안 잡히면(`require("next/headers")`·동적 import·재수출) **이름만 보는 종전
2431
+ * 판정으로 되돌아간다**. 과탐은 사람이 지우면 되고 미탐은 관문이 눈먼 채 초록을 찍는다.
2432
+ */
2433
+ const NEXT_DYNAMIC_EXPORTS = new Map([
2434
+ ["next/headers", new Set(["cookies", "headers", "draftMode", "connection"])],
2435
+ ["next/cache", new Set(["unstable_noStore", "noStore"])],
2436
+ ]);
2437
+
2438
+ /** 이름만 보는 종전 판정 — 되돌아갈 자리. `why` 는 바인딩 판정과 같은 문자열을 쓴다. */
2439
+ const DYNAMIC_API_BY_NAME = [
2440
+ {re: /\bcookies\s*\(/, why: "cookies()"},
2441
+ {re: /\bheaders\s*\(/, why: "headers()"},
2442
+ {re: /\bdraftMode\s*\(/, why: "draftMode()"},
2443
+ {re: /\bconnection\s*\(/, why: "connection()"},
2444
+ {re: /\b(?:unstable_noStore|noStore)\s*\(/, why: "unstable_noStore()"},
2445
+ ];
2446
+
2447
+ const reEscape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2448
+
2449
+ /**
2450
+ * 이 파일이 실제로 부르는 Next 동적 API — `["cookies()", …]`.
2451
+ *
2452
+ * `raw` 는 원문(파서용) · `text` 는 주석만 지운 사본(모듈 이름 탐지용 — 지정자는 문자열에 산다) ·
2453
+ * `code` 는 주석과 문자열까지 지운 사본(호출 검출용) · `where` 는 경로다.
2454
+ */
2455
+ function nextDynamicCalls(raw, text, code, where) {
2456
+ const byName = () => DYNAMIC_API_BY_NAME.filter(({re}) => re.test(code)).map(({why}) => why);
2457
+
2458
+ const read = fromStatements(raw, where);
2459
+ if (!read.reliable) {
2460
+ markUnreadable(where, "C1 동적 API 바인딩 판독");
2461
+ return byName(); // 못 읽었다 — 이름만으로 본다(미탐보다 과탐)
2462
+ }
2463
+
2464
+ const direct = new Map(); // 지역이름 → 원래이름
2465
+ const namespaces = []; // {local, spec}
2466
+ for (const st of read.statements) {
2467
+ if (st.kw !== "import") continue;
2468
+ const exported = NEXT_DYNAMIC_EXPORTS.get(st.spec);
2469
+ if (exported === undefined) continue;
2470
+ for (const {local, imported} of st.locals ?? []) {
2471
+ if (imported === "*") namespaces.push({local, spec: st.spec});
2472
+ else if (exported.has(imported)) direct.set(local, imported);
2473
+ }
2474
+ }
2475
+
2476
+ // 모듈 이름이 소스에 **보이는데** import 로는 안 잡혔다 — `require`·동적 import·재수출 등
2477
+ // 우리가 못 읽는 경로로 들어왔을 수 있다. 그 파일에서는 이름만 보는 판정으로 되돌아간다.
2478
+ //
2479
+ // ⚠ **주석 제거본에서 본다.** 원문에서 보면 「이건 next/headers 와 무관한 우리 헬퍼다」라고
2480
+ // 적어 둔 **설명 주석**이 되돌아감을 켜서, 방금 고친 오탐이 그대로 되살아난다(실측).
2481
+ // 지정자는 문자열 리터럴에 사니 주석만 지운 사본이 정확한 자리다.
2482
+ if (direct.size === 0 && namespaces.length === 0) {
2483
+ for (const spec of NEXT_DYNAMIC_EXPORTS.keys()) {
2484
+ if (text.includes(spec)) return byName();
2485
+ }
2486
+ return [];
2487
+ }
2488
+
2489
+ const found = new Set();
2490
+ for (const [local, imported] of direct) {
2491
+ if (new RegExp(`\\b${reEscape(local)}\\s*\\(`).test(code)) found.add(`${imported}()`);
2492
+ }
2493
+ for (const {local, spec} of namespaces) {
2494
+ for (const name of NEXT_DYNAMIC_EXPORTS.get(spec)) {
2495
+ if (new RegExp(`\\b${reEscape(local)}\\s*\\.\\s*${name}\\s*\\(`).test(code)) found.add(`${name}()`);
2496
+ }
2497
+ }
2498
+ return [...found];
2499
+ }
2500
+
2501
+ /** 이름 축을 안 태우는 나머지 — 형태가 곧 Next 계약이라 출처를 물을 것이 없다. */
1879
2502
  const DYNAMIC_API = [
1880
- {re: /\bcookies\s*\(/, why: "cookies()", corpus: "code"},
1881
- {re: /\bheaders\s*\(/, why: "headers()", corpus: "code"},
1882
- {re: /\bdraftMode\s*\(/, why: "draftMode()", corpus: "code"},
1883
- {re: /\bconnection\s*\(/, why: "connection()", corpus: "code"},
1884
- {re: /\b(?:unstable_noStore|noStore)\s*\(/, why: "unstable_noStore()", corpus: "code"},
1885
2503
  {re: /export\s+const\s+revalidate\s*=\s*0\b/, why: "export const revalidate = 0", corpus: "code"},
1886
2504
  {re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: "fetch(..., {next: {revalidate: 0}})", corpus: "code"},
1887
2505
  {re: /cache\s*:\s*["']no-store["']/, why: `fetch(..., {cache: "no-store"})`, corpus: "text"},
@@ -1950,23 +2568,43 @@ function stripCommentsAndStrings(src) {
1950
2568
  }
1951
2569
 
1952
2570
  /** `@/x`·상대경로만 해석한다. 외부 패키지(next·react·@zalkera/client)는 추적 대상이 아니다. */
2571
+ const resolveImportCache = new Map();
2572
+
1953
2573
  function resolveImport(spec, fromFile) {
2574
+ // ⚠ **같은 (지정자, 부르는 파일)을 두 번 풀지 않는다.** 그래프 훑기는 지정자를 중복 제거하지
2575
+ // 않으므로, `export * from "./m"` 이 2만 줄이면 **같은 키를 2만 번** 해석한다(실측).
2576
+ // 해석은 파일시스템을 성분마다 두드리므로 그 반복이 곧 비용이다.
2577
+ const key = `${fromFile}\u0000${spec}`;
2578
+ const cached = resolveImportCache.get(key);
2579
+ if (cached !== undefined) return cached;
2580
+
1954
2581
  let base;
1955
2582
  if (spec.startsWith("@/")) base = join(root, spec.slice(2));
1956
2583
  else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
1957
- else return null;
2584
+ else {
2585
+ resolveImportCache.set(key, null);
2586
+ return null;
2587
+ }
1958
2588
 
1959
2589
  // ⚠ 후보 **마다** 봉쇄한다 — `base` 만 검사하면 마지막 성분이 심링크인 `src/lib/x.ts` 가 샌다.
2590
+ let found = null;
1960
2591
  for (const cand of [base, ...[".ts", ".tsx", ".js", ".jsx"].flatMap((e) => [base + e, join(base, "index" + e)])]) {
1961
2592
  const safe = containedPath(repoRootDir, cand);
1962
2593
  if (safe === null) continue; // 레포 밖 — 따라가지 않는다
2594
+ // ⚠ **여기서 `fileExists` 를 쓰지 않는다.** 그 함수는 봉쇄를 다시 도는데, `safe` 는 바로
2595
+ // 위에서 **같은 뿌리로** 봉쇄를 통과한 값이다 — 같은 walk 를 두 번 도는 셈이라
2596
+ // `containedPath` 호출이 정확히 2배가 됐다(실측). 봉쇄는 이미 끝났으므로 실재만 묻는다.
1963
2597
  try {
1964
- if (statSync(safe).isFile()) return safe;
2598
+ if (statSync(safe).isFile()) {
2599
+ found = safe;
2600
+ break;
2601
+ }
1965
2602
  } catch {
1966
2603
  /* 다음 후보 */
1967
2604
  }
1968
2605
  }
1969
- return null;
2606
+ resolveImportCache.set(key, found);
2607
+ return found;
1970
2608
  }
1971
2609
 
1972
2610
  /**
@@ -1983,16 +2621,12 @@ function resolveImport(spec, fromFile) {
1983
2621
  * 빼지 않으면 layout 이 `import type {SessionInfo} from "@/lib/session"` 만 해도 session.ts 의
1984
2622
  * `cookies()` 가 잡히는 순수 오탐이 난다.
1985
2623
  */
1986
- function importSpecifiers(src) {
1987
- const noTemplates = src.replace(/`(?:\\[\s\S]|[^\\`])*`/g, "``");
1988
- const out = [];
1989
- for (const re of [
1990
- /^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']/gm,
1991
- /^\s*export\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']/gm,
1992
- ]) {
1993
- for (const m of noTemplates.matchAll(re)) out.push(m[1]);
1994
- }
1995
- return out;
2624
+ function importSpecifiers(src, where) {
2625
+ // 템플릿 제거가 더는 필요 없다 — 파서가 문자열 안의 코드 조각을 문장으로 세지 않는다.
2626
+ const read = fromStatements(src, where);
2627
+ if (!read.reliable) markUnreadable(where, "서버 그래프 간선 수집");
2628
+ // 타입 전용 들여오기는 컴파일 시 사라지므로 그래프의 간선이 아니다.
2629
+ return read.statements.filter(({bindsValue}) => bindsValue).map(({spec}) => spec);
1996
2630
  }
1997
2631
 
1998
2632
  /**
@@ -2034,8 +2668,10 @@ function dynamicApiReachableFrom(entry) {
2034
2668
  for (const {re, why, corpus} of DYNAMIC_API) {
2035
2669
  if (re.test(corpus === "text" ? text : code)) found.push({file, why, chain});
2036
2670
  }
2671
+ // 이름 축은 **바인딩 출처**로 가른다 — 고객의 보통 `headers()` 헬퍼가 관문을 막지 않도록.
2672
+ for (const why of nextDynamicCalls(raw, text, code, file)) found.push({file, why, chain});
2037
2673
  // import 는 문자열을 남긴 사본에서 — 지운 사본에서 뽑으면 지정자가 사라져 그래프가 끊긴다.
2038
- for (const spec of importSpecifiers(text)) {
2674
+ for (const spec of importSpecifiers(raw, file)) {
2039
2675
  const next = resolveImport(spec, file);
2040
2676
  if (next && !seen.has(next)) queue.push({file: next, chain: [...chain, next]});
2041
2677
  }
@@ -2051,6 +2687,54 @@ function remedyFor(why) {
2051
2687
  return `layout 은 정적으로 두고, 신선도가 필요하면 태그 fetch(\`{tags}\`) + 온디맨드 revalidate 를 써라`;
2052
2688
  }
2053
2689
 
2690
+ /**
2691
+ * **C1 — SEO page 의 동적 렌더 폭발반경.**
2692
+ *
2693
+ * ⚠ 종전 C1 은 page **본문 텍스트만** 봤다. 형제 C1b 는 같은 축을 `dynamicApiReachableFrom()` 으로
2694
+ * 그래프까지 좇는데, page 축만 한 겹이었다. 그래서 `cookies()` 를 부르는 헬퍼를 **layout 이**
2695
+ * import 하면 `rc=1`, **page 가** import 하면 `rc=0` 이었다(심의 실증). C1b 주석이 스스로 적어 둔
2696
+ * 실제 사고 형상("`cookies()` 가 layout 이 아니라 import 한 컴포넌트 안에 있었다")이 page 축에
2697
+ * 그대로 남아 있었던 것이다.
2698
+ *
2699
+ * 폭발반경 차이(layout=아래 전부 / page=그 라우트)는 **심각도**의 근거이지 **탐지 여부**의 근거가
2700
+ * 아니다. 판정기는 하나를 쓰고, 문면으로 반경을 구분한다.
2701
+ *
2702
+ * 규칙표도 하나로 모인다 — 종전 `SSR_FORBIDDEN` 은 `draftMode()`·`connection()`·
2703
+ * `unstable_noStore()` 를 안 봤고 `DYNAMIC_API` 는 봤다. 같은 축의 두 표가 갈려 있었다.
2704
+ */
2705
+ function checkPageBlastRadius() {
2706
+ for (const page of seoPageFiles) {
2707
+ const reported = new Set();
2708
+ for (const {file, why, chain} of dynamicApiReachableFrom(page)) {
2709
+ const key = `${file}|${why}`;
2710
+ if (reported.has(key)) continue;
2711
+ reported.add(key);
2712
+
2713
+ // 면제는 **범인 파일**에 붙인다(C1b 와 같은 규칙) — page 에 붙이면 그 아래 import 가 한 번에 뚫린다.
2714
+ const allow = (readSafe(file, "C1 면제 마커 판독") ?? "").match(
2715
+ /\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:[ \t]*(\S.*)/,
2716
+ );
2717
+ const own = file === page;
2718
+ const where = own
2719
+ ? `${relative(process.cwd(), page)}: SEO 라우트가 동적SSR 을 유발한다 → ${why}`
2720
+ : `${relative(process.cwd(), page)} 이 ${why} 에 도달한다 → ` +
2721
+ `${chain.map((f) => relative(process.cwd(), f)).join(" → ")}`;
2722
+ if (allow) {
2723
+ warnings.push(`[C1] ${where} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
2724
+ continue;
2725
+ }
2726
+ const marker = own
2727
+ ? "`// zalkera-allow-dynamic: <이유>` 마커로 정당화하라"
2728
+ : `${relative(process.cwd(), file)} 에 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하라 ` +
2729
+ `(마커는 page 가 아니라 **이 파일**에 붙어야 듣는다)`;
2730
+ servingSink().push(
2731
+ `[C1] ${where}. SEO 페이지는 ISR(export const revalidate = N) 또는 static 이어야 한다. ` +
2732
+ `${remedyFor(why)}. 동적 SSR 이 꼭 필요하면 ${marker}.`,
2733
+ );
2734
+ }
2735
+ }
2736
+ }
2737
+
2054
2738
  function checkLayoutBlastRadius() {
2055
2739
  // 범인이 같으면 고칠 곳도 하나다 — 조상 layout 수만큼 반복 출력하지 않는다.
2056
2740
  const reported = new Set();
@@ -2105,10 +2789,12 @@ function checkLayoutBlastRadius() {
2105
2789
  function checkThemeWiring() {
2106
2790
  if (STYLE_MODE !== "declared") return; // 선언 없는 레포의 주입 부재는 결함이 아니라 정상이다.
2107
2791
 
2108
- const globalsCss = join(root, "app", "globals.css");
2792
+ // 진입점은 루트 파일이 알려 준다 — 자리를 박으면 정상 레포가 영구히 막힌다([styleEntry]).
2793
+ const {entry: globalsCss} = styleEntry();
2109
2794
  // 부재는 S3 가 이미 error 로 잡는다(같은 사실을 두 번 외치지 않는다). 그러나 **있는데 못 읽는 것**은
2110
2795
  // 다른 사실이고, 조용히 return 하면 S8 이 통째로 사라진 채 ✅ 가 난다(심의 실측).
2111
- const css = readSafe(globalsCss, "S8 테마 토큰 검사(globals.css)");
2796
+ if (globalsCss === null) return;
2797
+ const css = readSafe(globalsCss, "S8 테마 토큰 검사(스타일 진입점)");
2112
2798
  if (css === null) return;
2113
2799
 
2114
2800
  // S8-a — 토큰 정의.
@@ -2120,16 +2806,15 @@ function checkThemeWiring() {
2120
2806
  }
2121
2807
 
2122
2808
  // S8-b — 주입 배선.
2123
- const rootLayout = ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]
2124
- .map((n) => join(root, "app", n))
2125
- .find((p) => {
2126
- try {
2127
- return statSync(p).isFile();
2128
- } catch {
2129
- return false;
2130
- }
2131
- });
2132
- if (!rootLayout) return; // root layout 부재는 C1 계열의 몫.
2809
+ //
2810
+ // **이 축은 App Router 전용이다 — 그것이 계약이 정의된 자리이기 때문이다.** L1 표현 계약은
2811
+ // root layout 의 `<html style={...}>` 에 색을 싣는 형태로 발행돼 있다. Pages Router 에는
2812
+ // 그 자리가 없고(`<Html>` 은 `_document` 에 산다) 우리가 그 형태로 계약을 발행한 적도 없다.
2813
+ // 그러니 여기서 잴 **약속 자체가 없다** — 「못 쟀다」가 아니라 계약 범위 밖이라 스킵한다.
2814
+ // (S3·S5·S8-a Pages Router 에서도 그대로 돈다. 스타일축이 통째로 꺼지지는 않는다.)
2815
+ const rootFile = appRootFile();
2816
+ if (rootFile === null || rootFile.kind !== "app") return; // root layout 부재는 C1 계열의 몫.
2817
+ const rootLayout = rootFile.path;
2133
2818
 
2134
2819
  const raw = readSafe(rootLayout, "테마 주입 배선 검사(root layout)");
2135
2820
  if (raw === null) return;
@@ -2208,6 +2893,18 @@ function sectionContractMap() {
2208
2893
  */
2209
2894
  function warnIfContractMissing(contract) {
2210
2895
  if (contract) return;
2896
+ // ⚠ **관문에서는 막는다.** 아래 문단이 "error 로 막으면 설치 전 훑어보기가 불가능해진다"라고
2897
+ // 적은 것은 **권고 모드**의 사정이다. `--gate` 는 서빙을 인증하는 자리이고, 거기서 계약을 못
2898
+ // 읽었다는 것은 N4·N5 를 **안 돌렸다**는 뜻이다 — 같은 파일이 "못 잰 것"에 이미 fail-closed
2899
+ // (rc=7)를 걸어 두었는데 이 축만 새고 있었다(심의 실증: 미지 섹션 타입 픽스처가 rc=1 → rc=0).
2900
+ if (GATE_MODE) {
2901
+ markUnmeasured(
2902
+ "@zalkera/client (SECTION_CONTRACT)",
2903
+ "ENOENT",
2904
+ "섹션 계약 검사(N4·N5) — 계약을 못 읽어 안 돌렸습니다. `npm ci` 후 다시 돌리십시오",
2905
+ );
2906
+ return;
2907
+ }
2211
2908
  warnings.push(
2212
2909
  "[W-CONTRACT] @zalkera/client 를 못 읽어 **섹션 계약 검사(N4·N5)를 건너뛰었습니다** — " +
2213
2910
  "미지 섹션·빠진 필수 참조가 무검출입니다. `npm ci` 후 다시 돌리십시오.",
@@ -2263,7 +2960,7 @@ function checkContentContract() {
2263
2960
  const manifestPath = join(repoRoot, "content", "index.ts");
2264
2961
  // ⚠ 종전엔 EACCES 도 "없습니다"로 보고했다 — 있는데 못 읽는 것을 부재로 말하면 고칠 자리가 어긋난다.
2265
2962
  const manifest = readSafe(manifestPath, "N1 콘텐츠 매니페스트");
2266
- if (manifest === null && existsSync(manifestPath)) return; // 못 읽음은 markUnmeasured 가 적었다
2963
+ if (manifest === null && fileExists(manifestPath)) return; // 못 읽음은 markUnmeasured 가 적었다
2267
2964
  if (manifest === null) {
2268
2965
  sink.push(
2269
2966
  `[N1] ${rel(manifestPath)} 가 없습니다 — 콘텐츠 매니페스트(정적 import)가 없으면 ` +
@@ -2410,7 +3107,7 @@ function checkContentContract() {
2410
3107
  // ⚠ **파일이어야 한다.** 디렉터리도 `statSync` 는 성공하므로 `/img` 같은 값이
2411
3108
  // "실재"로 통과했다 — 이 축의 존재 이유인 "개시하면 깨진 이미지"를 놓친다.
2412
3109
  // 형제 호출 네 곳은 이미 `.isFile()` 을 쓴다.
2413
- if (!statSync(assetAt).isFile()) throw new Error("not a file");
3110
+ if (!fileExists(assetAt)) throw new Error("not a file");
2414
3111
  } catch {
2415
3112
  sink.push(
2416
3113
  `[N5] ${at}: "${path}" 가 가리키는 public${bare} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`,
@@ -2452,15 +3149,18 @@ function checkContentContract() {
2452
3149
  }
2453
3150
 
2454
3151
  function check(file) {
2455
- let src;
2456
- try {
2457
- src = readFileSync(file, "utf8");
2458
- } catch (e) {
2459
- // 권한 없는 파일. 종전엔 여기서 스택트레이스로 죽었다(심의 처방이 닫은 자리 — 그 처방은
2460
- // `readdirSync` 축만 봤다).
2461
- markUnmeasured(file, e.code, "파일 읽기");
2462
- return;
2463
- }
3152
+ // ⚠ **`readSafe` 를 쓴다.** 종전에는 이 자리만 맨몸 `readFileSync` 였다 — 이 검사기가 읽는 파일의
3153
+ // 대부분이 여기를 지나는데, 봉쇄(`containedPath`)도 크기 상한(`MAX_READ_BYTES`)도 안 탔다.
3154
+ // 같은 실행 안에서 `readSafe` 는 "레포 밖을 가리켜 읽지 않았습니다"라고 적고 이 자리는 이미
3155
+ // 읽어서 내용을 회신하는, 한 출력 안의 자기모순이 났다(심의 실증).
3156
+ // **절대경로로 넘긴다.** `readSafe` `containedPath(repoRootDir, file)` `resolve(from, spec)`
3157
+ // 이라 **상대경로를 레포 루트 기준으로 다시 접는다.** `check()` 받는 경로는 CWD 기준이므로
3158
+ // 그대로 넘기면 `<repo>/presets/skeleton/presets/skeleton/src/...` 같은 없는 경로가 되어
3159
+ // **113개 전부 조용히 null** 이 됐다(수리 중 실측 — 검사기가 통째로 눈이 멀고도 출력은 멀쩡했다).
3160
+ // `readSafe` 의 다른 호출처들은 이미 레포 루트에서 조립한 경로를 넘겨서 안 드러났다.
3161
+ const src = readSafe(resolve(file), "파일 읽기");
3162
+ if (src === null) return;
3163
+ inspectedCount += 1;
2464
3164
  const rel = relative(process.cwd(), file);
2465
3165
  // ⚠ **파일 머리만 본다(심의 차단 4).** 종전은 `/m` 플래그로 **원문 전체의 행머리**를 봤고,
2466
3166
  // 행머리에 `"use client"` 를 담은 **템플릿 리터럴 한 줄**이 있으면 SEO 페이지가 클라이언트로
@@ -2481,37 +3181,33 @@ function check(file) {
2481
3181
  }
2482
3182
 
2483
3183
  if (isClient) {
2484
- // E1: import(= import type 아님)로 @zalkera/client 들여옴(구 @oneque/client 잡는다).
2485
- const valueImport = /^import\s+(?!type\s)[^;]*from\s+["']@(?:zalkera|oneq(?:ue?)?)\/client["']/m.test(src);
2486
- if (valueImport) {
2487
- servingSink().push(
2488
- `[E1] ${rel}: "use client" 파일에서 @zalkera/client 값으로 import 한다 (타입은 \`import type\` 으로).`,
3184
+ // ⚠ **E1·E2 I2 같은 판정기를 쓴다.** 종전에는 셋이 각자 정규식을 썼고, E1 의 것은
3185
+ // `^import ... from` 한 줄뿐이라 **동적 import·재수출로 통째로 우회**됐다(심의 실증:
3186
+ // `const {createZalkeraClient: mk} = await import("@zalkera/client")`
3187
+ // `export {createZalkeraClient} from "@zalkera/client"` 둘 다 관문 `rc=0`).
3188
+ // E2 의 것은 `lib/zalkera` 직후 닫는 따옴표를 요구해 `lib/zalkera/index`·`lib/zalkera.ts`
3189
+ // 놓쳤다 — I2 는 같은 자리를 `(?:\/[^"']*)?` 로 이미 흡수하고 있었다.
3190
+ //
3191
+ // E1(패키지)과 E2(싱글턴)는 **문면이 다르므로** 지정자로 갈라 각각 낸다.
3192
+ // ⚠ **주석을 지운 사본을 넘긴다.** [fromStatements] 는 그것을 전제로 선언돼 있고, 형제
3193
+ // 소비자(그래프 추적·I2)는 이미 지키는데 여기만 원문을 넘겼다. 그러면 양쪽으로 틀린다 —
3194
+ // 주석으로 지운 import 가 관문을 막고(E1 에는 면제 마커가 없어 탈출구가 없다),
3195
+ // `import {x} /* … */ from "@zalkera/client"` 는 통과한다.
3196
+ // 파서는 주석을 알아서 건너뛴다 — 지운 사본을 넘기면 문자열이 깨져 파싱이 실패한다.
3197
+ const runtimeImport = importsClientAtRuntime(src, rel);
3198
+ if (runtimeImport) {
3199
+ const viaPackage = new RegExp(String.raw`["']@(?:zalkera|oneq(?:ue?)?)\/client["']`).test(src);
3200
+ const viaSingleton = new RegExp(String.raw`["'][^"']*lib\/(?:zalkera|oneq(?:ue?)?)(?:\/[^"']*)?["']`).test(
3201
+ src,
2489
3202
  );
2490
- }
2491
- // E2: 서버 싱글턴(lib/zalkera) import(구 lib/oneque 도 잡는다).
2492
- if (/from\s+["'][^"']*lib\/(?:zalkera|oneq(?:ue?)?)["']/.test(src)) {
2493
- servingSink().push(`[E2] ${rel}: "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera)을 import 한다.`);
2494
- }
2495
- }
2496
-
2497
- // C1: ISR-우선 게이트 — SEO 라우트 page 는 per-page SSR 을 강제할 수 없다.
2498
- if (isSeoPageFile(file) && !isClient) {
2499
- // ⚠ **주석을 지운 사본에서 잰다.** 원문에 대고 재면 "예전엔 여기서 cookies() 를 읽었다" 같은
2500
- // 설명 주석이 그대로 오류가 된다 — 이 파일이 그 함정을 스스로 적어 뒀고(C1b 는 그렇게 고쳤다)
2501
- // C1 만 원문에 남아 있었다. 게다가 유일한 탈출구인 `zalkera-allow-dynamic` 마커는 동적 SSR 을
2502
- // 안 쓰는 페이지에 "동적 SSR 이 필요하다"는 **거짓 사유**를 적게 만든다.
2503
- // 마커 탐색은 아래에서 원문에 대고 한다 — 마커는 주석이라 지운 사본엔 없다.
2504
- const hits = SSR_FORBIDDEN.filter(({re}) => re.test(stripComments(src))).map(({why}) => why);
2505
- if (hits.length > 0) {
2506
- const allow = src.match(/\/\/\s*(?:zalkera|oneq(?:ue?)?)-allow-dynamic:\s*(.+)/);
2507
- const detail = `${rel}: SEO 라우트가 동적SSR 을 유발한다 → ${hits.join("; ")}`;
2508
- if (allow) {
2509
- warnings.push(`[C1] ${detail} — 예외 허용(zalkera-allow-dynamic: ${allow[1].trim()}).`);
2510
- } else {
3203
+ if (viaPackage) {
3204
+ servingSink().push(
3205
+ `[E1] ${rel}: "use client" 파일에서 @zalkera/client 를 값으로 들여온다 (타입은 \`import type\` 으로).`,
3206
+ );
3207
+ }
3208
+ if (viaSingleton) {
2511
3209
  servingSink().push(
2512
- `[C1] ${detail}. SEO 페이지는 ISR(export const revalidate = N) 또는 static 이어야 한다. ` +
2513
- `실시간·개인화 값은 클라이언트 컴포넌트(아일랜드)로, 상태 변경은 BFF route handler 로 옮겨라. ` +
2514
- `동적 SSR 이 꼭 필요하면 \`// zalkera-allow-dynamic: <이유>\` 마커로 정당화하라.`,
3210
+ `[E2] ${rel}: "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera) 들여온다.`,
2515
3211
  );
2516
3212
  }
2517
3213
  }
@@ -2530,7 +3226,9 @@ function check(file) {
2530
3226
  if (alien.length > 0) {
2531
3227
  s6.push(
2532
3228
  `[S6] ${rel}: 남의 토큰 어휘 ${alien.join("·")} — 우리 @theme 에 없는 이름이라 색이 빠진다. ` +
2533
- `우리 어휘로 옮겨라(bg-card→bg-surface, text-muted-foreground→text-muted 등).`,
3229
+ // 예시를 **공백으로 띄우지 않는다** — 위 잣대가 `(^|[\s"'`])` 시작해
3230
+ // 띄우면 이 줄 자신이 S6 에 걸린다(실측: 반입 레포 rc=1).
3231
+ `우리 어휘로 옮겨라(bg-card→bg-surface·text-muted-foreground→text-muted 등).`,
2534
3232
  );
2535
3233
  }
2536
3234
  }
@@ -2582,7 +3280,8 @@ function check(file) {
2582
3280
  // 빌드 시 값을 클라이언트 번들에 **문자 그대로 박아 넣는다**는 프레임워크 계약이라, 그 접두가
2583
3281
  // 붙은 시크릿은 "노출될 수도 있다"가 아니라 **이미 노출된 것**이다(.env.example §12 가 같은 말을
2584
3282
  // 문장으로 적어 뒀는데 검사기는 한 번도 안 봤다). 소스에 박은 키 리터럴도 같은 축이다.
2585
- for (const hit of secretExposures(text)) servingSink().push(`[E3] ${rel}: ${hit}`);
3283
+ // 값 잣대는 **원문**(`src`)에서, 이름 잣대는 주석 제거본(`text`)에서 [secretExposures] 참조.
3284
+ for (const hit of secretExposures(text, src)) servingSink().push(`[E3] ${rel}: ${hit}`);
2586
3285
 
2587
3286
  // I1: 방문자 IP 를 XFF **첫 엔트리**에서 뽑는다 — 방문자가 위조할 수 있는 값이다(I 절 주석).
2588
3287
  // 경고이되 **받아 줄 곳을 가리킨다**: 대체물이 없는 경고는 방치로 끝난다.
@@ -2600,7 +3299,7 @@ function check(file) {
2600
3299
  // ⚠ `"use client"` 파일은 건너뛴다. 그런 파일이 클라이언트를 값으로 들여오는 것 자체가 E1·E2 **오류**이고,
2601
3300
  // 거기에 대고 `visitorIp(headers)` 를 처방하면 **브라우저에 없는 API** 를 가리키게 된다 — 오류 위에
2602
3301
  // 얹힌 틀린 경고는 사람을 잘못된 수선으로 보낸다(심의 관찰).
2603
- const missing = isClient ? null : missingClientIp(src, holdsJsx(file, src));
3302
+ const missing = isClient ? null : missingClientIp(src, holdsJsx(file, src), file);
2604
3303
  if (missing) {
2605
3304
  clientIpDeclarationSink().push(
2606
3305
  `[I2] ${rel}: ${missing.join("·")} 를 서버에서 부르는데 \`clientIp\` 선언이 없습니다 — ` +
@@ -2612,63 +3311,139 @@ function check(file) {
2612
3311
  }
2613
3312
  }
2614
3313
 
3314
+ /*
3315
+ * ── 스타일 진입점 : 좌표를 **복사하지 않고 유도한다** ────────────────────────────────
3316
+ *
3317
+ * S3·S5·S8 이 재는 명제는 「`<src>/app/globals.css` 가 있는가」가 아니다. **앱의 루트가 스타일시트
3318
+ * 하나를 실제로 싣고, 그것이 Tailwind 배선과 테마 토큰을 나르는가**이다. 파일 이름과 자리는 그
3319
+ * 명제의 부수물이다.
3320
+ *
3321
+ * 종전엔 `join(root, "app", "globals.css")` 를 세 자리에 박아 뒀다. 그래서 **정상적으로 배선된
3322
+ * 레포가 영구히 관문을 통과하지 못했다**(실측 2형상):
3323
+ *
3324
+ * · `src/styles/globals.css` + `import "../styles/globals.css"` — Next 의 흔한 관례.
3325
+ * → `[S3] …/app/globals.css 가 없습니다 … 복구하세요` + `[S5] styles/globals.css 는 난립`.
3326
+ * 파일은 **있고** 배선도 **맞는데** 없다고 말하고, 이미 한 일을 하라고 처방한다.
3327
+ * · Pages Router(`pages/_app.tsx` + `../styles/globals.css`) — 같은 파일이 C1p·C1pa·X1p 로
3328
+ * **일부러 지원하는** 형상인데 스타일축이 그 레포를 통째로 막았다.
3329
+ *
3330
+ * 유도의 출처는 추측이 아니다 — **루트 파일이 자기 스타일시트를 직접 선언한다.** Next 가 전역
3331
+ * CSS 를 싣는 자리는 딱 거기 하나이고(App Router 는 root layout, Pages Router 는 `_app`),
3332
+ * 우리가 읽는 것도 그 한 줄이다. 좌표를 파생시키지 않으므로 심링크 우회도 봉쇄를 그대로 지난다.
3333
+ */
3334
+ const STYLE_ROOT_CANDIDATES = [
3335
+ ["app", ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]],
3336
+ ["pages", ["_app.tsx", "_app.jsx", "_app.ts", "_app.js"]],
3337
+ ];
3338
+
3339
+ let appRootCache;
3340
+
3341
+ /** 앱의 루트 파일. `{path, kind}` 또는 부재면 `null`. App Router 를 먼저 본다. */
3342
+ function appRootFile() {
3343
+ if (appRootCache !== undefined) return appRootCache;
3344
+ for (const [dir, names] of STYLE_ROOT_CANDIDATES) {
3345
+ for (const n of names) {
3346
+ const candidate = join(root, dir, n);
3347
+ try {
3348
+ if (fileExists(candidate)) return (appRootCache = {path: candidate, kind: dir});
3349
+ } catch {
3350
+ /* 다음 후보 */
3351
+ }
3352
+ }
3353
+ }
3354
+ return (appRootCache = null);
3355
+ }
3356
+
3357
+ /**
3358
+ * 루트가 적은 CSS 지정자를 실경로로. **확장자를 추측하지 않는다** — CSS import 는 전체 이름을 적는다.
3359
+ * 패키지 CSS(`import "swiper/css"`)는 우리 레포 파일이 아니므로 `null` 이다.
3360
+ */
3361
+ function resolveCssImport(spec, fromFile) {
3362
+ let target;
3363
+ if (spec.startsWith("@/")) target = join(root, spec.slice(2));
3364
+ else if (spec.startsWith(".")) target = resolve(dirname(fromFile), spec);
3365
+ else return null;
3366
+ return containedPath(repoRootDir, target); // 레포 밖이면 null — 봉쇄는 그대로다
3367
+ }
3368
+
3369
+ let styleEntryCache;
3370
+
3371
+ /**
3372
+ * 이 레포의 스타일 진입점. `{rootFile, entry, specs}`.
3373
+ * · `rootFile` — 루트 파일(`{path, kind}`) 또는 `null`
3374
+ * · `specs` — 루트가 side-effect 로 import 하는 `.css` 지정자 전부(선언된 것)
3375
+ * · `entry` — 그중 **실재하는** 첫 파일의 실경로 또는 `null`
3376
+ *
3377
+ * 바인딩이 붙은 import(`import s from "./x.module.css"`)는 **일부러 안 센다** — 그건 컴포넌트
3378
+ * 스코프 CSS 모듈이지 전역 스타일시트가 아니고, Next 도 그렇게 가른다.
3379
+ */
3380
+ function styleEntry() {
3381
+ if (styleEntryCache !== undefined) return styleEntryCache;
3382
+ const rootFile = appRootFile();
3383
+ if (rootFile === null) return (styleEntryCache = {rootFile: null, entry: null, specs: []});
3384
+ const raw = readSafe(rootFile.path, "S3 스타일 진입점 판독(루트 파일)");
3385
+ // 못 읽음은 readSafe 가 이미 「못 잰 것」으로 적었다.
3386
+ if (raw === null) return (styleEntryCache = {rootFile, entry: null, specs: []});
3387
+ const code = stripComments(raw); // 주석 속 옛 import 를 배선으로 세지 않는다
3388
+ const specs = [...code.matchAll(/import\s+["']([^"']+\.css)["']/g)].map((m) => m[1]);
3389
+ let entry = null;
3390
+ for (const spec of specs) {
3391
+ const resolved = resolveCssImport(spec, rootFile.path);
3392
+ if (resolved !== null && fileExists(resolved)) {
3393
+ entry = resolved;
3394
+ break;
3395
+ }
3396
+ }
3397
+ return (styleEntryCache = {rootFile, entry, specs});
3398
+ }
3399
+
2615
3400
  /**
2616
3401
  * S3·S5 — Tailwind 배선(단일 CSS)의 존재·정합. codegen 이 배선을 지우거나 CSS 파일을 난립시키는
2617
- * 회귀를 막는다. 파일 존재 + import 문자열 검사면 충분하다(§5.1).
3402
+ * 회귀를 막는다. 진입점은 [styleEntry] 루트 파일에서 **읽어 온다**(자리를 박지 않는다).
2618
3403
  */
2619
3404
  function checkStyleWiring() {
2620
- const globalsCss = join(root, "app", "globals.css");
3405
+ const {rootFile, entry, specs} = styleEntry();
2621
3406
 
2622
- // S3: Tailwind 배선(globals.css 존재 + root layout import). none 모드는 스킵.
3407
+ // S3: 루트가 CSS 싣는가 · CSS 가 실재하는가. none 모드는 스킵.
2623
3408
  const s3 = styleSink("S3");
2624
3409
  if (s3) {
2625
- let globalsOk = false;
2626
- try {
2627
- globalsOk = statSync(globalsCss).isFile();
2628
- } catch {
2629
- globalsOk = false;
2630
- }
2631
-
2632
- if (!globalsOk) {
3410
+ if (rootFile === null) {
3411
+ // ⚠ **이것은 「못 쟀다」가 아니라 「없다」이다.** Next 가 전역 CSS 를 실을 수 있는 자리는
3412
+ // root layout 과 `_app` 둘뿐이다 — 그 파일이 없으면 스타일시트가 실릴 경로 자체가
3413
+ // 존재하지 않는다. 「못 잼」(rc=7)으로 적었더니 이미 **잰** 위반들(S2·S4…)을 덮어
3414
+ // 사람이 목록에서 지웠다(시험이 잡았다). 판정은 다른 규칙과 같은 문으로 낸다 —
3415
+ // 모드 게이팅(declared=error · inferred=warning)도 그래야 그대로 산다.
2633
3416
  s3.push(
2634
- `[S3] ${relative(process.cwd(), globalsCss)} 없습니다 — ` +
2635
- `Tailwind 배선(@import "tailwindcss" + @theme 토큰) 사라졌습니다. globals.css 복구하세요.`,
3417
+ `[S3] app/layout.* · pages/_app.* 찾지 못했습니다 전역 CSS 를 실을 자리가 ` +
3418
+ `없습니다. Next root layout(App Router) 또는 _app(Pages Router)에서만 ` +
3419
+ `전역 스타일시트를 로드합니다.`,
3420
+ );
3421
+ } else if (specs.length === 0) {
3422
+ s3.push(
3423
+ `[S3] ${relative(process.cwd(), rootFile.path)} 이 CSS 를 import 하지 않습니다 ` +
3424
+ `(\`import "./globals.css"\` 같은 한 줄). 배선이 없으면 Tailwind CSS·테마 토큰이 ` +
3425
+ `로드되지 않습니다 — 자리는 어디든 좋고, 루트가 싣기만 하면 됩니다.`,
3426
+ );
3427
+ } else if (entry === null) {
3428
+ s3.push(
3429
+ `[S3] ${relative(process.cwd(), rootFile.path)} 이 import 하는 CSS 를 찾지 못했습니다: ` +
3430
+ `${specs.join(" · ")} — 파일이 지워졌거나 경로가 어긋났습니다(레포 밖을 가리키는 ` +
3431
+ `심링크도 "없음"으로 봅니다). Tailwind 배선(@import "tailwindcss" + @theme 토큰)을 복구하세요.`,
2636
3432
  );
2637
- } else {
2638
- // root layout(app/layout.*)이 globals.css 를 import 하는지 — 안 하면 스타일이 전혀 안 실린다.
2639
- const rootLayout = ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]
2640
- .map((n) => join(root, "app", n))
2641
- .find((p) => {
2642
- try {
2643
- return statSync(p).isFile();
2644
- } catch {
2645
- return false;
2646
- }
2647
- });
2648
- if (rootLayout) {
2649
- const layoutText = readSafe(rootLayout, "S3 globals.css import 검사");
2650
- const src = stripComments(layoutText ?? "");
2651
- if (layoutText !== null && !/import\s+["'][^"']*globals\.css["']/.test(src)) {
2652
- s3.push(
2653
- `[S3] ${relative(process.cwd(), rootLayout)} 이 globals.css 를 import 하지 않습니다 ` +
2654
- `(\`import "./globals.css"\`). 배선이 없으면 Tailwind CSS·테마 토큰이 로드되지 않습니다.`,
2655
- );
2656
- }
2657
- }
2658
3433
  }
2659
3434
  }
2660
3435
 
2661
- // S5: globals.css 외의 CSS 파일 난립 방지(단일 CSS 원칙). none 모드는 스킵.
3436
+ // S5: 진입점 **외의** CSS 파일 난립 방지(단일 CSS 원칙). none 모드는 스킵.
2662
3437
  const s5 = styleSink("S5");
2663
3438
  if (s5) {
2664
- const globalsAbs = resolve(globalsCss);
3439
+ const entryAbs = entry === null ? null : resolve(entry);
3440
+ const where = entry === null ? "루트가 싣는 CSS" : relative(process.cwd(), entry);
2665
3441
  for (const css of cssFiles) {
2666
- if (resolve(css) !== globalsAbs) {
2667
- s5.push(
2668
- `[S5] ${relative(process.cwd(), css)} — src/app/globals.css 외의 CSS 파일. ` +
2669
- `단일 CSS 원칙: 스타일은 Tailwind 유틸리티 클래스로, 색·폰트는 globals.css @theme 토큰으로.`,
2670
- );
2671
- }
3442
+ if (entryAbs !== null && resolve(css) === entryAbs) continue;
3443
+ s5.push(
3444
+ `[S5] ${relative(process.cwd(), css)} — ${where} 외의 CSS 파일. ` +
3445
+ `단일 CSS 원칙: 스타일은 Tailwind 유틸리티 클래스로, 색·폰트는 진입점의 @theme 토큰으로.`,
3446
+ );
2672
3447
  }
2673
3448
  }
2674
3449
  }
@@ -2902,9 +3677,14 @@ function judgeGuardPlacement(rawBody) {
2902
3677
  }
2903
3678
  const rest = body.replace(/^\s+/, "");
2904
3679
  const CALL = "(?:\\w+\\s*\\.\\s*)?assertSameOrigin\\s*\\("; // 네임스페이스 import 허용
3680
+ // ⚠ **`await` 를 허용한다.** 가드가 async 인 레포(요청 헤더를 비동기로 읽는 형상)에서는
3681
+ // `const b = await assertSameOrigin(req)` 가 **정석**인데, 종전 정규식이 `= assertSameOrigin`
3682
+ // 만 봐서 그 정석을 「본문의 첫 구문이 아닙니다」로 반려했다(실측 — if 갈래도 같았다).
3683
+ // 우리 팩의 가드는 동기라 우리 트리에서는 안 드러났다.
3684
+ const AWAITED = `(?:await\\s+)?${CALL}`;
2905
3685
 
2906
3686
  // ① 첫 구문이 선언형 가드인가 — 타입 주석·세미콜론 유무를 묻지 않는다.
2907
- const decl = rest.match(new RegExp(`^(?:const|let|var)\\s+(\\w+)\\b[^=;]*=\\s*${CALL}`));
3687
+ const decl = rest.match(new RegExp(`^(?:const|let|var)\\s+(\\w+)\\b[^=;]*=\\s*${AWAITED}`));
2908
3688
  if (decl) {
2909
3689
  // ② 그 이름이 return 에 닿는가. `if (x) return x` · `if (x) { return x }` · `if (x !== null)`
2910
3690
  // 전부 이 한 줄로 통과한다.
@@ -2920,18 +3700,42 @@ function judgeGuardPlacement(rawBody) {
2920
3700
  // ⚠ `return` 을 **본문 아무 데서나** 찾으면 안 된다 — 그러면 `if (가드) { /* 삼킨다 */ }` 뒤의
2921
3701
  // 정상 `return` 하나로 충족돼 **차단하지 않는 가드**가 통과한다(실측). "반환값 버림"이
2922
3702
  // if 갈래로 재발한 것이라, `return` 은 **그 if 의 본문 안**에서만 인정한다.
2923
- if (new RegExp(`^if\\s*\\(\\s*${CALL}`).test(rest)) {
3703
+ if (new RegExp(`^if\\s*\\(\\s*${AWAITED}`).test(rest)) {
2924
3704
  if (new RegExp(`^if\\s*\\([\\s\\S]*?\\)\\s*(?:\\{[^}]*\\breturn\\b|\\breturn\\b)`).test(rest)) return null;
2925
3705
  return `가 가드에 걸린 요청을 return 으로 끊지 않습니다 — 차단이 성립하지 않습니다.`;
2926
3706
  }
2927
3707
 
2928
- // 가드가 어디에도 없다 vs 구문이 아니다 — 사유를 갈라 준다(고치는 방법이 다르다).
3708
+ // 가드가 어디에도 없다 다른 갈래들과 사유가 다르다(고치는 방법이 다르다).
2929
3709
  if (!new RegExp(CALL).test(body)) {
2930
3710
  return (
2931
3711
  `가 변이 메서드인데 assertSameOrigin 호출이 없습니다 — 교차사이트 위조가 열립니다.` +
2932
3712
  ` 정당한 예외면 파일 상단에 \`// zalkera-allow-cross-origin: 이유\` 를 다세요.`
2933
3713
  );
2934
3714
  }
3715
+
3716
+ // ③ 첫 구문이 **맨 호출**이다 — 가드는 돌지만 반환값을 버려 아무것도 막지 않는다.
3717
+ //
3718
+ // ⚠ 종전엔 이 형상이 마지막 갈래로 떨어져 「본문의 첫 구문이 아닙니다 … 맨 앞에 두세요」라고
3719
+ // 답했다. 가드는 **이미 맨 앞에 있다** — 처방이 이미 한 일을 하라는 말이었다. 탐지는 맞고
3720
+ // 사유가 틀렸던 자리라, 결함(반환값 버림)을 그대로 적는다. 선언형 갈래의 사유와 같은 축이다.
3721
+ if (new RegExp(`^${AWAITED}`).test(rest)) {
3722
+ return (
3723
+ `를 본문 첫 구문에서 부르지만 **반환값을 버립니다** — 호출만으로는 요청이 막히지 않습니다.` +
3724
+ ` \`const blocked = assertSameOrigin(req); if (blocked) return blocked;\` 형태를 쓰세요.`
3725
+ );
3726
+ }
3727
+
3728
+ // ④ 가드가 첫머리에 있긴 한데 우리가 아는 차단 형태가 아니다(예: \`return 가드(req) ?? …\`).
3729
+ // **「첫 구문이 아니다」라고 단정하지 않는다** — 그 말이 거짓인 자리가 이미 한 번 있었다.
3730
+ if (new RegExp(`^(?:return\\s+)${AWAITED}`).test(rest)) {
3731
+ return (
3732
+ `를 첫 구문에서 부르지만 **우리가 아는 차단 형태가 아닙니다** — 이 검사기가 읽는 것은` +
3733
+ ` \`const b = assertSameOrigin(req); if (b) return b;\` 와 \`if (assertSameOrigin(req)) return …\`` +
3734
+ ` 둘입니다. 둘 중 하나로 쓰시거나, 의도한 형태라면 파일 상단에` +
3735
+ ` \`// zalkera-allow-cross-origin: 이유\` 를 다세요.`
3736
+ );
3737
+ }
3738
+
2935
3739
  return (
2936
3740
  `의 assertSameOrigin 이 **본문의 첫 구문이 아닙니다** — 앞선 쿠키 쓰기는 403 응답에 Set-Cookie 를` +
2937
3741
  ` 실어 "가드에 막힌 요청은 아무 상태도 안 남긴다"를 깹니다. 가드를 감싸거나(헬퍼·중첩 함수) 뒤로 미루지` +
@@ -3134,11 +3938,7 @@ function checkCrossOriginGuards() {
3134
3938
  /** Pages Router 루트(`<src>/pages` 또는 레포 루트 `pages`). 없으면 null — 부재는 결함이 아니다. */
3135
3939
  function pagesRoot() {
3136
3940
  for (const cand of [join(root, "pages"), join(repoRootDir, "pages")]) {
3137
- try {
3138
- if (statSync(cand).isDirectory()) return cand;
3139
- } catch {
3140
- /* 다음 후보 */
3141
- }
3941
+ if (dirExists(cand)) return cand;
3142
3942
  }
3143
3943
  return null;
3144
3944
  }
@@ -3314,7 +4114,7 @@ function isStaticallyReadableConfig(text) {
3314
4114
 
3315
4115
  function checkServingOutputContract() {
3316
4116
  const repoRoot = repoRootDir;
3317
- const found = NEXT_CONFIG_NAMES.map((n) => join(repoRoot, n)).filter((p) => existsSync(p));
4117
+ const found = NEXT_CONFIG_NAMES.map((n) => join(repoRoot, n)).filter((p) => fileExists(p));
3318
4118
  // 설정 파일이 여럿이면 어느 것이 유효한지 Next 의 해석 순서에 달렸다 — 못 가르는 자리라 스킵한다.
3319
4119
  if (found.length !== 1) return;
3320
4120
 
@@ -3348,19 +4148,31 @@ function checkServingOutputContract() {
3348
4148
  // ENOTDIR 를 "선택적 하위 디렉터리 부재"로 관용하므로, 그대로 두면 훑을 것이 0개인 채 `✅ rc=0` 으로
3349
4149
  // 끝난다(심의 실측 — 구판은 크래시로나마 관문을 닫았다). 선택은 하위 좌표의 성질이지 시작 좌표의
3350
4150
  // 성질이 아니다. 여기서 틀리면 **검사를 한 줄도 안 돌고 통과**가 난다.
4151
+ // ⚠ **표시는 상대로 접는다.** 위에서 소스 루트를 `resolve()` 로 폈으므로 이 문면에 그대로 실으면
4152
+ // `/codebuild/output/src…/src` 같은 **우리 빌드 박스의 절대경로**가 나온다. 이 줄은 관문 모드에서
4153
+ // `/_gate-status` logTail 로 테넌트에게 간다. 판정은 절대 좌표로, 표시는 부른 자리 기준으로.
4154
+ const shownRelative = relative(process.cwd(), root);
4155
+ const shownRoot =
4156
+ shownRelative === ""
4157
+ ? "."
4158
+ : // cwd 밖(`..` 로 시작)이거나 cwd 가 루트면 상대 표기가 오히려 거짓이 된다 — 그땐 절대로 둔다.
4159
+ shownRelative.startsWith("..") || process.cwd() === sep
4160
+ ? root
4161
+ : shownRelative;
3351
4162
  try {
3352
4163
  if (!statSync(root).isDirectory()) {
3353
- console.error(`디렉터리가 아닙니다(소스 디렉터리를 주십시오 · 보통 ./src): ${root}`);
4164
+ console.error(`디렉터리가 아닙니다(소스 디렉터리를 주십시오 · 보통 ./src): ${shownRoot}`);
3354
4165
  console.error(`검사를 시작하지 못했습니다 — 통과가 아닙니다.`);
3355
4166
  process.exit(2);
3356
4167
  }
3357
4168
  } catch {
3358
- console.error(`디렉터리를 찾을 수 없습니다: ${root}`);
4169
+ console.error(`디렉터리를 찾을 수 없습니다: ${shownRoot}`);
3359
4170
  process.exit(2);
3360
4171
  }
3361
4172
 
3362
4173
  walk(root);
3363
4174
  checkLayoutBlastRadius(); // walk 가 layoutFiles 를 채운 뒤에.
4175
+ checkPageBlastRadius(); // walk 가 seoPageFiles 를 채운 뒤에. C1b 와 같은 판정기를 쓴다.
3364
4176
  checkStyleWiring(); // S3·S5 — walk 가 cssFiles 를 채운 뒤에.
3365
4177
  checkThemeWiring(); // S8 — L1 배선(declared 전용).
3366
4178
  checkSectionCoverage();
@@ -3395,7 +4207,7 @@ function checkManualCarrier() {
3395
4207
  // catch 로 빠졌고, 사본을 훼손해도 경고가 안 났다(변이 실측). 있는데 안 도는 검사기가 없는 것보다 나쁘다.
3396
4208
  const projectRoot = repoRootDir;
3397
4209
  const at = join(projectRoot, "llms.txt");
3398
- if (!existsSync(at)) return; // 없으면 스킵 — 지운 것은 자유.
4210
+ if (!fileExists(at)) return; // 없으면 스킵 — 지운 것은 자유.
3399
4211
  let carried;
3400
4212
  try {
3401
4213
  const req = createRequire(join(projectRoot, "package.json"));
@@ -3404,7 +4216,7 @@ function checkManualCarrier() {
3404
4216
  } catch {
3405
4217
  return; // client 미설치 — 여기서 판정할 것이 없다.
3406
4218
  }
3407
- if (!existsSync(carried)) return;
4219
+ if (!fileExists(carried)) return;
3408
4220
  // ⚠ **`readSafe` 를 쓴다.** 양쪽 다 테넌트가 통제하는 경로다(`llms.txt` 와 설치본). 맨몸
3409
4221
  // `readFileSync` 는 ⑴ 레포 밖 심링크를 따라가 **내용 동일성 오라클**이 되고(맞히면 침묵,
3410
4222
  // 틀리면 경고 — 길이 오라클도 겸한다) ⑵ 크기 상한을 통째로 우회한다(실측 RSS 354MB).
@@ -3456,6 +4268,18 @@ for (const u of unmeasured) {
3456
4268
  unmeasuredByPath.get(key).whats.add(u.what);
3457
4269
  }
3458
4270
 
4271
+ // **잴 것이 아예 없었다.** 빈 트리·오타난 경로·전부 필터에 걸린 트리. 관문에서는 통과가 아니다.
4272
+ if (inspectedCount === 0) {
4273
+ const say = GATE_MODE ? console.error : console.warn;
4274
+ const mark = GATE_MODE ? "❌" : "⚠️ ";
4275
+ say(`\n${mark} 검사한 소스 파일이 **0개**입니다 — 아무것도 인증하지 않았습니다(통과가 아닙니다).`);
4276
+ say(` 경로가 맞는지 확인하십시오(빈 디렉터리·오타·필터에 전부 걸린 트리).`);
4277
+ if (GATE_MODE) {
4278
+ console.error(`\n검사를 끝내지 못했습니다. rc=7(검사 불능) — 규약 위반(rc=1)과 다른 뜻입니다.`);
4279
+ process.exit(7);
4280
+ }
4281
+ }
4282
+
3459
4283
  if (unmeasuredByPath.size > 0) {
3460
4284
  const say = GATE_MODE ? console.error : console.warn;
3461
4285
  const mark = GATE_MODE ? "❌" : "⚠️ ";