@zalkera/client 0.21.1 → 0.21.3

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,9 +124,9 @@
124
124
  * 이 파일은 **인자로 받은 소스 디렉터리 기준으로만** 동작한다(레포 고정 경로 0). 그래서 어느
125
125
  * 체크아웃에서든, 압축을 푼 zip 안에서든 똑같이 돈다.
126
126
  */
127
- import {existsSync, readdirSync, readFileSync, statSync} from "node:fs";
127
+ import {existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync} from "node:fs";
128
128
  import {createRequire} from "node:module";
129
- import {basename, dirname, join, relative, resolve, sep} from "node:path";
129
+ import {basename, dirname, isAbsolute, join, relative, resolve, sep} from "node:path";
130
130
 
131
131
  /**
132
132
  * 검사할 **소스 루트**(`src/`). 세 가지를 흡수한다 — 셋 다 실제로 사람이 치는 형태다.
@@ -151,17 +151,105 @@ function resolveSourceRoot() {
151
151
  return given; // 둘 다 아니면 준 대로 두고 아래 존재 검사가 말하게 한다
152
152
  }
153
153
 
154
+ /**
155
+ * **못 잰 것**(unmeasured) — 통과도 위반도 아닌 셋째 칸.
156
+ *
157
+ * 종전엔 이 칸이 없어서 "읽지 못함"이 둘 중 하나로 접혔다:
158
+ * - `walk` 의 `readdirSync`/`statSync` 는 **예외를 그대로 터뜨려** raw 스택트레이스 + rc=1 로 죽었다.
159
+ * rc=1 은 "규약 위반 N개"와 같은 코드라, 수신자가 **도구가 죽은 것과 소스가 틀린 것을 못 가린다.**
160
+ * 실측: 심링크 루프·끊어진 심링크·권한 없는 디렉터리 셋 다 규칙이 **0개 돌기 전에** 죽었다.
161
+ * - 형제 수집기들의 bare `catch {}` 는 **조용히 통과**시켰다. `.env` 를 못 읽으면 E3(시크릿 유출)가
162
+ * 아무 고지 없이 사라졌다(실측).
163
+ *
164
+ * `build.sh` 는 이 구분을 이미 한다 — `GATE-WARN … 통과가 아니라 미검사입니다` 를 두 번 외친다.
165
+ * 그 문장이 **파일 단위로도** 성립해야 한다. 그래서 셋째 칸을 1급으로 둔다.
166
+ *
167
+ * 처분은 모드로 갈린다(아래 종료 블록): 권고 모드는 경고·rc 0(고객 나무의 상태를 우리가 벌하지 않는다),
168
+ * 관문 모드는 fail-closed·**rc 7**. 관문 트리는 심링크 fail-closed·모드 0644 고정이라 이 칸이 뜨는 것이
169
+ * 구성상 불가능하고, 뜨면 그 자체가 변조·IO 이상 신호다 — 오탐 비용이 0이다.
170
+ */
171
+ const unmeasured = [];
172
+
173
+ /** 못 잰 것을 적는다. 사유(errno)와 무엇을 못 했는지를 함께 남긴다 — "안 잰 것을 잰 척" 하지 않기 위해. */
174
+ function markUnmeasured(path, code, what) {
175
+ unmeasured.push({path: relative(process.cwd(), path), code: code ?? "UNKNOWN", what});
176
+ }
177
+
178
+ /**
179
+ * 못 읽으면 **적고 `null` 을 준다.** 조용한 `catch {}` 와 다르다 — 부재의 사유가 남는다.
180
+ *
181
+ * 이 검사기의 "못 읽음" 처분이 종전엔 네 가지로 갈려 있었다(심의 실측): 크래시(walk·AGENTS.md) ·
182
+ * 조용한 소멸(`.env`·`globals.css`) · 오분류 고지(N2 가 IO 오류를 "JSON 파싱 실패"로) · 통과.
183
+ * 한 자리로 모은다.
184
+ */
185
+ function readSafe(file, what) {
186
+ try {
187
+ return readFileSync(file, "utf8");
188
+ } catch (e) {
189
+ if (e.code === "ENOENT") return null; // 없는 파일은 "못 잰 것"이 아니다 — 규칙이 판단할 사실이다
190
+ markUnmeasured(file, e.code, what);
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * **안전망** — 예기치 못한 예외가 raw 스택트레이스로 나가지 않게 한다.
197
+ *
198
+ * `build.sh` 가 스스로 적어 둔 「종료코드의 수신자는 비개발자다」가 근거다. 종전엔 파일시스템 예외
199
+ * 하나가 Node 스택 8줄 + `errno: -40` 으로 튀어나왔고, `verify-zip` 은 그것을 **납품 반려 사유로
200
+ * 그대로 인쇄**했다(실측). 납품사는 무엇을 고치라는 것인지 알 수 없었고, 실제로는 규약 검사가
201
+ * **0줄 돌았다.**
202
+ *
203
+ * rc=2(도구 오류)를 쓴다 — rc=1 은 "규약 위반"의 뜻이라 도구가 죽은 것과 섞이면 안 된다.
204
+ * 스택은 `ZALKERA_VALIDATE_DEBUG=1` 일 때만 보인다.
205
+ */
206
+ process.on("uncaughtException", (err) => {
207
+ console.error(`\n❌ 검사기가 예기치 못한 오류로 멈췄습니다 — 규약 검사가 끝나지 않았습니다(통과가 아닙니다).`);
208
+ console.error(` ${err?.code ? `[${err.code}] ` : ""}${err?.message ?? err}`);
209
+ if (err?.path) console.error(` 대상: ${err.path}`);
210
+ console.error(
211
+ ` 개발자에게 이 줄을 전달하십시오. 자세한 스택은 ZALKERA_VALIDATE_DEBUG=1 로 다시 실행하면 나옵니다.`,
212
+ );
213
+ if (process.env.ZALKERA_VALIDATE_DEBUG === "1") console.error(err);
214
+ process.exit(2);
215
+ });
216
+
154
217
  const root = resolveSourceRoot();
155
218
 
219
+ /**
220
+ * 레포 루트. **소스 루트의 부모가 아니다** — 소스 루트가 레포 루트 자신일 수 있다.
221
+ *
222
+ * ⚠ 종전엔 7곳이 각자 `resolve(root, "..")` 를 썼다. BYO 레포처럼 `app/` 이 레포 루트에 바로 있으면
223
+ * 그 좌표가 **레포 밖**을 가리켜, 이웃 디렉터리의 `.env` 가 이 레포의 E3 로 잡히고(관문 rc=1)
224
+ * 자기 `content/`·`next.config`·`AGENTS.md` 는 조용히 스킵됐다 — `build.sh` 가 BYO 에 쓰는
225
+ * `--gate .` 경로에서 **N1 거짓 오류로 관문이 섰다**(두 심의자가 독립적으로 같은 자리를 지목).
226
+ *
227
+ * 좌표가 흩어져 있던 것이 구조적 원인이라 **한 자리에서 정한다.**
228
+ */
229
+ function resolveRepoRoot(sourceRoot) {
230
+ return basename(sourceRoot) === "src" ? resolve(sourceRoot, "..") : resolve(sourceRoot);
231
+ }
232
+
233
+ const repoRootDir = resolveRepoRoot(root);
234
+
156
235
  /**
157
236
  * shadcn 기본 토큰 어휘(재작성 표의 좌변·memo102 §4.1). 우리 @theme 에 없는 이름이라 클래스가 생성되지
158
237
  * 않는다 — 색이 빠진 채로 조용히 배포되는 종류의 사고라 declared 모드에서 error 다.
159
238
  * 주의: 우리 `muted` 는 **글자색**이라 `bg-muted`(shadcn 은 배경)와 의미가 다르다.
160
239
  */
161
240
  const FOREIGN_TOKEN_CLASSES = [
162
- "bg-card", "bg-popover", "bg-muted", "bg-accent", "bg-destructive",
163
- "text-card-foreground", "text-popover-foreground", "text-muted-foreground", "text-accent-foreground",
164
- "text-destructive", "ring-ring", "border-input",
241
+ "bg-card",
242
+ "bg-popover",
243
+ "bg-muted",
244
+ "bg-accent",
245
+ "bg-destructive",
246
+ "text-card-foreground",
247
+ "text-popover-foreground",
248
+ "text-muted-foreground",
249
+ "text-accent-foreground",
250
+ "text-destructive",
251
+ "ring-ring",
252
+ "border-input",
165
253
  ];
166
254
 
167
255
  /*
@@ -183,8 +271,21 @@ const FOREIGN_TOKEN_CLASSES = [
183
271
  * 검정이고 브랜드 색 표면이 아니다. 넣으면 정상 코드가 대량으로 빨개진다. 즉 이 축은 **안 잰다**.
184
272
  */
185
273
  const COLOR_UTILITY_ROOTS = new Set([
186
- "bg", "text", "border", "ring", "outline", "decoration", "divide",
187
- "placeholder", "caret", "accent", "fill", "stroke", "from", "via", "to",
274
+ "bg",
275
+ "text",
276
+ "border",
277
+ "ring",
278
+ "outline",
279
+ "decoration",
280
+ "divide",
281
+ "placeholder",
282
+ "caret",
283
+ "accent",
284
+ "fill",
285
+ "stroke",
286
+ "from",
287
+ "via",
288
+ "to",
188
289
  ]);
189
290
  /** 색 함수. `color-mix(` 까지 — Tailwind 임의값 안에서 전부 유효하다. */
190
291
  const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\s*\(/;
@@ -197,13 +298,65 @@ const HEX_COLOR = /#[0-9a-fA-F]{3,8}(?![0-9a-zA-Z])/;
197
298
  * 아니라 위임이라 일부러 뺐다(Tailwind 에 `bg-transparent`·`text-current` 가 이미 있다).
198
299
  */
199
300
  const NAMED_COLORS = new Set([
200
- "red", "blue", "green", "black", "white", "gray", "grey", "orange", "purple", "pink", "yellow",
201
- "brown", "cyan", "magenta", "navy", "teal", "olive", "maroon", "lime", "aqua", "silver", "gold",
202
- "indigo", "violet", "crimson", "coral", "salmon", "tomato", "khaki", "beige", "ivory", "azure",
203
- "plum", "orchid", "turquoise", "lavender", "tan", "wheat", "snow", "skyblue", "hotpink",
204
- "darkblue", "darkred", "darkgreen", "lightblue", "lightgray", "lightgrey", "midnightblue",
205
- "steelblue", "slategray", "slategrey", "firebrick", "forestgreen", "seagreen", "royalblue",
206
- "dodgerblue", "chocolate", "sienna", "peru",
301
+ "red",
302
+ "blue",
303
+ "green",
304
+ "black",
305
+ "white",
306
+ "gray",
307
+ "grey",
308
+ "orange",
309
+ "purple",
310
+ "pink",
311
+ "yellow",
312
+ "brown",
313
+ "cyan",
314
+ "magenta",
315
+ "navy",
316
+ "teal",
317
+ "olive",
318
+ "maroon",
319
+ "lime",
320
+ "aqua",
321
+ "silver",
322
+ "gold",
323
+ "indigo",
324
+ "violet",
325
+ "crimson",
326
+ "coral",
327
+ "salmon",
328
+ "tomato",
329
+ "khaki",
330
+ "beige",
331
+ "ivory",
332
+ "azure",
333
+ "plum",
334
+ "orchid",
335
+ "turquoise",
336
+ "lavender",
337
+ "tan",
338
+ "wheat",
339
+ "snow",
340
+ "skyblue",
341
+ "hotpink",
342
+ "darkblue",
343
+ "darkred",
344
+ "darkgreen",
345
+ "lightblue",
346
+ "lightgray",
347
+ "lightgrey",
348
+ "midnightblue",
349
+ "steelblue",
350
+ "slategray",
351
+ "slategrey",
352
+ "firebrick",
353
+ "forestgreen",
354
+ "seagreen",
355
+ "royalblue",
356
+ "dodgerblue",
357
+ "chocolate",
358
+ "sienna",
359
+ "peru",
207
360
  ]);
208
361
 
209
362
  /**
@@ -300,7 +453,11 @@ function detectStyleMode(srcDir) {
300
453
  const pkgPath = join(dir, "package.json");
301
454
  try {
302
455
  if (statSync(pkgPath).isFile()) {
303
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
456
+ // 읽으면 모드가 조용히 강등되어 **S 규칙군이 통째로 스킵**되고 ✅ rc 0 이 난다
457
+ // (심의 실측). 선언을 안 한 것과 못 읽은 것은 다른 사실이다.
458
+ const declText = readSafe(pkgPath, "스타일 규약 모드 판정(package.json)");
459
+ if (declText === null) return "none";
460
+ const pkg = JSON.parse(declText);
304
461
  const styling = pkg?.zalkera?.styling ?? pkg?.oneque?.styling;
305
462
  if (styling === "tailwind-tokens") return "declared";
306
463
  if (styling !== undefined) return "none"; // 검사 규칙 없는 다른 선언값 = 잘커라 스타일 규약 없음
@@ -380,6 +537,25 @@ function servingSink() {
380
537
  * 반면 `E`(시크릿이 브라우저 번들에 실림)·`C`(SEO 라우트가 동적 SSR 강제)는 **사실을 잰다** — 오탐 여지가
381
538
  * 없고 상용 실측도 0건이라, 관문으로 켜도 아무도 안 막힌다. 그래서 그 둘만 `servingSink` 를 쓴다.
382
539
  */
540
+ /**
541
+ * **X 축(X1·X1p·X3) 전용 목적지 — 승격은 영구 금지다**(memo140 §6.5).
542
+ *
543
+ * [clientIpSink] 와 지금은 같은 배열로 가지만 **함수가 다르다.** 이유는 [clientIpDeclarationSink] 와 같다:
544
+ * 남이 I1 을 `servingSink()` 로 승격시키는 날, 싱크를 공유하면 X 축이 **딸려 올라간다.**
545
+ *
546
+ * **왜 영구 금지인가.** "다른 수단으로 막고 있으니까"가 아니다 — 그렇게 적었다가 보안 심의 실측에
547
+ * 반증됐다(상용 사이트가 교차 오리진 요청을 200 으로 접수). 근거는 **이 검사가 안전을 재지 못한다**
548
+ * 는 것이다: X1 은 *"교차 오리진을 막았는가"* 가 아니라 *"우리 심볼 `assertSameOrigin` 을 썼는가"* 를
549
+ * 잰다. 동명 로컬 함수를 선언하면 통과하므로 **통과가 안전을 뜻하지 않는다.** 재지 못하는 것으로
550
+ * 남의 빌드를 막으면, 막는 대가는 치르고 얻는 안전은 없다.
551
+ *
552
+ * 그 결정의 **나머지 절반**은 "막지 않는 대신 반드시 보여야 한다"이다 — 이 경고가 업로드·검수 결과에
553
+ * 실제로 뜨지 않으면 그냥 방치다. ⚠ 오늘 이 경고는 `/api/system` 에만 보인다. 고칠 수 있는 당사자
554
+ * (파트너)가 못 보는 상태이고, 그 노출은 아직 안 만들었다.
555
+ *
556
+ * 그리고 X3 는 **경고다.** memo118 §2 가 "X3 가 strict 회귀를 error 로 막는다"고 적어 뒀던 것은
557
+ * 사실이 아니었다(2026-08-14 정정 · 그 문장이 근거로 인용되던 자리라 문서도 같이 고쳤다).
558
+ */
383
559
  function crossOriginSink() {
384
560
  return warnings;
385
561
  }
@@ -454,9 +630,9 @@ function servingOutputSink() {
454
630
  // ⚠ 아래 조각들은 **일반 문자열**로 쓴다. 정규식 리터럴·템플릿 리터럴 안에 백틱을 담을 수 없어서다
455
631
  // (따옴표 세 종류를 다 흡수해야 하므로 백틱이 문자 클래스에 반드시 들어간다).
456
632
  /** 문자열 리터럴을 여는/닫는 따옴표 한 글자 — `"` · `'` · 백틱(```). */
457
- const QUOTE = '["\'\\u0060]';
633
+ const QUOTE = "[\"'\\u0060]";
458
634
  /** 따옴표가 아닌 한 글자(리터럴 안쪽). */
459
- const NOT_QUOTE = '[^"\'\\u0060]';
635
+ const NOT_QUOTE = "[^\"'\\u0060]";
460
636
 
461
637
  /** 헤더 이름 리터럴. Node(`req.headers["x-forwarded-for"]`)·web(`headers.get("x-forwarded-for")`) 양쪽을 흡수한다. */
462
638
  const XFF_NAME = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}`, "i");
@@ -471,10 +647,7 @@ const SPLIT_ON_COMMA = `\\.\\s*split\\s*\\(\\s*${QUOTE}${NOT_QUOTE}*,${NOT_QUOTE
471
647
  const FIRST_ENTRY = "(?:\\s*\\[\\s*0\\s*\\]|\\s*\\.\\s*at\\s*\\(\\s*0\\s*\\)|\\s*\\.\\s*shift\\s*\\(\\s*\\))";
472
648
 
473
649
  /** 헤더 리터럴 → split → 첫 엔트리가 **한 표현식으로** 이어진 형태. 사이의 `?.`·`!`·`.trim()` 등은 흡수한다. */
474
- const I1_DIRECT = new RegExp(
475
- `${QUOTE}x-forwarded-for${QUOTE}[\\s\\S]{0,120}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`,
476
- "i",
477
- );
650
+ const I1_DIRECT = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}[\\s\\S]{0,120}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`, "i");
478
651
 
479
652
  /** `const xff = <…x-forwarded-for…>` — 같은 파일 안 단순 변수 경유를 좇기 위한 바인딩 수집. */
480
653
  const I1_BINDING = new RegExp(
@@ -639,7 +812,10 @@ function importsClientAtRuntime(text) {
639
812
  if (!braces) return true; // 기본·네임스페이스·부수효과 import — 값이다
640
813
  const outside = clause.replace(/\{[^}]*\}/, "").replace(/[\s,]/g, "");
641
814
  if (outside.length > 0) return true; // `import z, {type Order} from …`
642
- const specifiers = braces[1].split(",").map((x) => x.trim()).filter(Boolean);
815
+ const specifiers = braces[1]
816
+ .split(",")
817
+ .map((x) => x.trim())
818
+ .filter(Boolean);
643
819
  if (specifiers.length === 0) return true; // `import {} from …` — 부수효과
644
820
  if (specifiers.some((x) => !/^type\s/.test(x))) return true;
645
821
  }
@@ -672,7 +848,8 @@ function prescription(called) {
672
848
  const parts = [];
673
849
  if (direct.length > 0) parts.push(`${direct.join("·")} 는 \`(첫 인자, {clientIp})\``);
674
850
  if (viaAccess.length > 0) parts.push(`${viaAccess.join("·")} 는 \`(no, {phone, context: {clientIp}})\``);
675
- if (third.length > 0) parts.push(`${third.join("·")} 는 **세 번째** 인자라 \`(no, params, {phone, context: {clientIp}})\``);
851
+ if (third.length > 0)
852
+ parts.push(`${third.join("·")} 는 **세 번째** 인자라 \`(no, params, {phone, context: {clientIp}})\``);
676
853
  return parts.join(" · ");
677
854
  }
678
855
 
@@ -704,7 +881,24 @@ function prescription(called) {
704
881
  * 아래 문자 뒤라면 값이 올 자리이므로 정규식이고, 그 밖(식별자·`)`·숫자 뒤)이면 나눗셈이다.
705
882
  */
706
883
  const BEFORE_REGEX_LITERAL = new Set([
707
- "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "~", "^",
884
+ "(",
885
+ ",",
886
+ "=",
887
+ ":",
888
+ "[",
889
+ "!",
890
+ "&",
891
+ "|",
892
+ "?",
893
+ "{",
894
+ "}",
895
+ ";",
896
+ "+",
897
+ "-",
898
+ "*",
899
+ "%",
900
+ "~",
901
+ "^",
708
902
  ]);
709
903
 
710
904
  /**
@@ -731,7 +925,20 @@ const BEFORE_REGEX_WORD = new Set([
731
925
  // 지금은 낱말이 아니라 **형태**로 가른다(아래 [isRegexStart]): `/` 다음이 공백·`{` 이거나 본문에
732
926
  // 리터럴 공백이 있으면 산문이다. 그래서 뺐던 낱말을 도로 넣어 잃었던 탐지를 회수했고,
733
927
  // 산문 6형상은 그대로 안 걸린다(심의 실측). 낱말 목록은 값-자리 판별용이지 산문 방어가 아니다.
734
- "return", "throw", "typeof", "void", "await", "yield", "instanceof", "case", "else", "in", "of", "new", "delete", "do",
928
+ "return",
929
+ "throw",
930
+ "typeof",
931
+ "void",
932
+ "await",
933
+ "yield",
934
+ "instanceof",
935
+ "case",
936
+ "else",
937
+ "in",
938
+ "of",
939
+ "new",
940
+ "delete",
941
+ "do",
735
942
  ]);
736
943
 
737
944
  /**
@@ -938,7 +1145,10 @@ function maskCode(text, jsx = false) {
938
1145
  */
939
1146
  function callArguments(source, code, fn) {
940
1147
  const found = [];
941
- const opener = new RegExp(`(?:\\.|\\?\\.)\\s*${fn}\\s*(?:\\?\\.)?\\s*\\(|\\[\\s*["'\`]${fn}["'\`]\\s*\\]\\s*\\(`, "g");
1148
+ const opener = new RegExp(
1149
+ `(?:\\.|\\?\\.)\\s*${fn}\\s*(?:\\?\\.)?\\s*\\(|\\[\\s*["'\`]${fn}["'\`]\\s*\\]\\s*\\(`,
1150
+ "g",
1151
+ );
942
1152
  // ⚠ 호출 **위치**는 원본에서 찾는다 — 브래킷 접근(`zalkera["getOrder"](…)`)은 이름 자체가 문자열
943
1153
  // 안이라 덮은 사본에서는 사라진다. 깊이만 덮은 사본에서 세면 된다(길이가 같아 오프셋이 맞는다).
944
1154
  for (const m of source.matchAll(opener)) {
@@ -1100,14 +1310,17 @@ const STYLE_MODE = detectStyleMode(root);
1100
1310
  * 여기서 하는 일은 판정을 바꾸는 것이 아니라 **판정 결과를 보이게 하는 것**뿐이다.
1101
1311
  */
1102
1312
  function detectContentMode(srcDir) {
1103
- const repoRoot = resolve(srcDir, "..");
1313
+ const repoRoot = resolveRepoRoot(srcDir);
1104
1314
  let declared;
1105
1315
  let dir = resolve(srcDir);
1106
1316
  for (let i = 0; i < 12; i++) {
1107
1317
  const pkgPath = join(dir, "package.json");
1108
1318
  try {
1109
1319
  if (statSync(pkgPath).isFile()) {
1110
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1320
+ // 위와 같은 자리 — 못 읽으면 N 규칙군이 통째로 스킵된다.
1321
+ const declText = readSafe(pkgPath, "콘텐츠 규약 모드 판정(package.json)");
1322
+ if (declText === null) break;
1323
+ const pkg = JSON.parse(declText);
1111
1324
  declared = pkg?.zalkera?.content ?? pkg?.oneque?.content;
1112
1325
  break;
1113
1326
  }
@@ -1121,9 +1334,9 @@ function detectContentMode(srcDir) {
1121
1334
  if (declared === "source") return "declared";
1122
1335
  if (declared === "sections-db") {
1123
1336
  warnings.push(
1124
- "[N0] package.json 의 `zalkera.content` 가 은퇴한 값 `\"sections-db\"` 입니다 — " +
1337
+ '[N0] package.json 의 `zalkera.content` 가 은퇴한 값 `"sections-db"` 입니다 — ' +
1125
1338
  "섹션이 백엔드 DB 에 살던 시절의 표기이고 그 거처는 퇴역했습니다(어휘 rev 7). " +
1126
- "사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `\"source\"` 로 바꾸세요 " +
1339
+ '사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `"source"` 로 바꾸세요 ' +
1127
1340
  "(llms.txt §9.1).",
1128
1341
  );
1129
1342
  return "none";
@@ -1156,7 +1369,10 @@ function contentPageFiles(repoRoot) {
1156
1369
  .filter((n) => n.endsWith(".json"))
1157
1370
  .sort()
1158
1371
  .map((n) => join(dir, n));
1159
- } catch {
1372
+ } catch (e) {
1373
+ // 콘텐츠 디렉터리는 **선택**이다 — 없는 것은 정상이고 "못 잰 것"이 아니다.
1374
+ // 그러나 있는데 못 읽는 것(권한·I/O)은 N 검사가 0줄 돈다는 뜻이라 적는다.
1375
+ if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(dir, e.code, "N 콘텐츠 페이지 수집");
1160
1376
  return [];
1161
1377
  }
1162
1378
  }
@@ -1184,11 +1400,28 @@ const DOC_PATH_TOKEN = /^[A-Za-z0-9_.\-/[\]]+\.(?:tsx?|jsx?|mjs|cjs|json|css|md)
1184
1400
  const DOC_PATH_SKIP_PREFIX = ["doc/", "node_modules/", ".zalkera/", "@", "/", "http"];
1185
1401
 
1186
1402
  /** 문서 본문에서 이 레포의 파일을 가리키는 것으로 판정되는 백틱 토큰. */
1403
+ /**
1404
+ * 문서에서 **좌표로 볼 백틱 토큰**을 고른다.
1405
+ *
1406
+ * ⚠ **`/` 요구는 실수가 아니라 정밀도의 근거다 — 넓히지 마라.** `.prettierrc.json` 처럼 슬래시 없는
1407
+ * 루트 파일이 안 재진다는 것은 사실이고, 그래서 두 가지 확장을 실측으로 재 봤다(2026-08-14):
1408
+ *
1409
+ * ⒜ **이름 좌표를 루트에서 찾기** — 우리 `AGENTS.md` 한 벌에서만 오탐 11건. `Button.tsx`·`page.tsx`·
1410
+ * `globals.css` 는 루트 파일이 아니라 **종류를 가리키는 이름**으로 쓰인다.
1411
+ * ⒝ **이름 좌표를 트리 전체에서 찾기** — 오탐이 문서당 2건으로 줄지만, 남은 6건이 **전부 정당한
1412
+ * 참조**였다: 설치본(`llms.txt`·`sections.ts` → `node_modules/@zalkera/client/`), 소비처가 가질
1413
+ * 파일(`robots.ts`·`sitemap.ts` — llms.txt 는 고객 앱을 설명한다), 남의 레포(`aeo-surface-guarantees.json`
1414
+ * → backend), 런타임 생성물(`robots.txt`). 즉 오탐률 100%다.
1415
+ *
1416
+ * D 규칙은 선언 레포에서 **error** 라 오탐 하나가 남의 빌드를 막는다. 못 잡는 좌표(루트 닷파일)의
1417
+ * 대가는 "죽은 링크 하나"이고, 잘못 잡는 대가는 "멀쩡한 납품물 반려"다. 대칭이 아니다 — 그래서
1418
+ * 슬래시가 있는 것만 좌표로 본다. **이 판단은 값이 아니라 비대칭에서 나온다.**
1419
+ */
1187
1420
  function docPathTokens(text, only) {
1188
1421
  const seen = new Set();
1189
1422
  for (const m of text.matchAll(/`([^`\n]+)`/g)) {
1190
1423
  const tok = m[1];
1191
- if (!tok.includes("/")) continue;
1424
+ if (!tok.includes("/")) continue; // 위 KDoc 의 실측 — 이름만 있는 토큰은 좌표가 아니다
1192
1425
  if (!DOC_PATH_TOKEN.test(tok)) continue;
1193
1426
  if (DOC_PATH_SKIP_PREFIX.some((p) => tok.startsWith(p))) continue;
1194
1427
  if (only && !only.test(tok)) continue;
@@ -1221,12 +1454,13 @@ function docsSink() {
1221
1454
  * (`sections.ts` 등)도 언급하기 때문이고, 그 셋이 템플릿 형상의 서브트리다.
1222
1455
  */
1223
1456
  function checkDocCoordinates() {
1224
- const repoRoot = resolve(root, "..");
1457
+ const repoRoot = repoRootDir;
1225
1458
  const sink = docsSink();
1226
1459
 
1227
1460
  const agents = join(repoRoot, "AGENTS.md");
1228
1461
  if (fileExists(agents)) {
1229
- for (const tok of docPathTokens(readFileSync(agents, "utf8"))) {
1462
+ const agentsText = readSafe(agents, "D1 문서 좌표 검사(AGENTS.md)");
1463
+ for (const tok of docPathTokens(agentsText ?? "")) {
1230
1464
  if (!fileExists(join(repoRoot, tok))) {
1231
1465
  sink.push(
1232
1466
  `[D1] AGENTS.md 가 없는 파일을 가리킵니다: \`${tok}\` — codegen 이 이 문서를 가장 먼저 ` +
@@ -1239,7 +1473,9 @@ function checkDocCoordinates() {
1239
1473
  // D2 — 본보기 레포 전용.
1240
1474
  let isExemplar = false;
1241
1475
  try {
1242
- isExemplar = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).name === "@zalkera/storefront-examples";
1476
+ isExemplar =
1477
+ JSON.parse(readSafe(join(repoRoot, "package.json"), "본보기 레포 판정") ?? "{}").name ===
1478
+ "@zalkera/storefront-examples";
1243
1479
  } catch {
1244
1480
  isExemplar = false;
1245
1481
  }
@@ -1247,7 +1483,8 @@ function checkDocCoordinates() {
1247
1483
 
1248
1484
  const llms = join(repoRoot, "node_modules", "@zalkera", "client", "llms.txt");
1249
1485
  if (!fileExists(llms)) return; // 미설치 — 없는 것을 센 척하지 않는다.
1250
- for (const tok of docPathTokens(readFileSync(llms, "utf8"), /^src\/(app|components|lib)\//)) {
1486
+ const llmsText = readSafe(llms, "D2 문서 좌표 검사(llms.txt)");
1487
+ for (const tok of docPathTokens(llmsText ?? "", /^src\/(app|components|lib)\//)) {
1251
1488
  if (!fileExists(join(repoRoot, tok))) {
1252
1489
  sink.push(
1253
1490
  `[D2] llms.txt 가 본보기로 지목한 \`${tok}\` 이 이 레포에 없습니다 — 레시피가 실물을 ` +
@@ -1302,7 +1539,10 @@ function isSeoPageFile(file) {
1302
1539
 
1303
1540
  // SEO page 에서 금지되는 per-page SSR 유발 패턴들.
1304
1541
  const SSR_FORBIDDEN = [
1305
- {re: /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/, why: `export const dynamic = "force-dynamic" (전 요청 SSR 강제)`},
1542
+ {
1543
+ re: /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/,
1544
+ why: `export const dynamic = "force-dynamic" (전 요청 SSR 강제)`,
1545
+ },
1306
1546
  {re: /export\s+const\s+revalidate\s*=\s*0\b/, why: `export const revalidate = 0 (ISR 무력화·매 요청 재생성)`},
1307
1547
  {re: /\bcookies\s*\(/, why: `page 레벨 cookies() 호출 (동적 렌더 opt-in — 세션값은 클라이언트 컴포넌트로)`},
1308
1548
  {re: /\bheaders\s*\(/, why: `page 레벨 headers() 호출 (동적 렌더 opt-in)`},
@@ -1310,12 +1550,155 @@ const SSR_FORBIDDEN = [
1310
1550
  {re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: `fetch(..., { next: { revalidate: 0 } }) (ISR 무력화)`},
1311
1551
  ];
1312
1552
 
1553
+ const WALK_MAX_DEPTH = 64;
1554
+
1555
+ /**
1556
+ * 서빙 빌드가 **소스 복사에서 버리는** 이름들 — `build.sh` 의 `tar --exclude` 거울이다.
1557
+ * 저쪽이 늘거나 줄면 여기도 같이 고쳐야 한다. 갈리면 "빌드엔 실리는데 검사는 안 되는" 자리가 생긴다.
1558
+ */
1559
+ const BUILD_DROPS = new Set(["node_modules", ".next", ".git"]);
1560
+
1561
+ /**
1562
+ * **나무를 훑는 자리는 여기 하나다.** 파일마다 `onFile(전체경로, 이름)` 을 부른다.
1563
+ *
1564
+ * 종전엔 훑기가 **네 벌**이었다(`walk`·X1 의 `collect`·X3 의 `collectSources`·`collectSourceFiles`).
1565
+ * 그래서 `walk` 하나만 고쳤을 때 나머지 셋은 옛 결함을 그대로 들고 있었다 — 같은 패치를 네 번
1566
+ * 덧대는 대신 훑기를 하나로 모은다. 규칙마다 다른 것은 **무엇을 줍느냐**이지 **어떻게 걷느냐**가 아니다.
1567
+ *
1568
+ * ## 디렉터리 심링크를 만나면 — **realpath 가 어느 구역인가**로 정한다
1569
+ *
1570
+ * | realpath 위치 | 처분 | 왜 |
1571
+ * | --- | --- | --- |
1572
+ * | **소스 루트 안** | 따라가지 않음 | 그 실디렉터리는 **어차피 제 자리에서 훑는다**. 별칭으로 또 들어가면 같은 파일을 두 번 세거나, 더 나쁘게 **별칭 경로로 보고**한다 |
1573
+ * | 레포 루트 안 · 소스 루트 밖 | **따라간다** | `src/app/shared -> ../../shared` 같은 정당한 공유 코드. 안 따라가면 그 안의 E1(시크릿)이 통째로 사라진다 |
1574
+ * | 레포 루트 **밖** | 따라가지 않고 **적는다** | 우리 도구가 남의 파일을 대신 읽어 주지 않는다. 납품 zip 은 신뢰 밖 코드이고 `unzip` 은 심링크를 복원한다 |
1575
+ *
1576
+ * ⚠ **첫 칸이 이 구조의 존재 이유다.** 초판은 전역 `visited` 집합 하나로 순환만 끊었는데, 그것이
1577
+ * **형제 별칭까지 잘라 냈다**(심의 실측): `src/aaa -> app` 이 있으면 `aaa` 를 먼저 만난 순간 실제
1578
+ * `src/app` 의 realpath 가 이미 visited 라 **원본 가지가 통째로 스킵**되고, 파일은 `src/aaa/...` 로만
1579
+ * 보고된다. 첫 세그먼트로 `app` 을 판정하는 규칙(C1·C1b·[isLayoutFile]·S3 배선)이 전부 침묵하고
1580
+ * `❌ rc=1` 이 `✅ rc=0` 으로 뒤집힌다. 더 나쁜 것은 **판정이 `readdirSync` 순서에 달렸다**는 점이다 —
1581
+ * `aaa` 는 깨지고 `zzz` 는 멀쩡했다. 관문이 비결정적이면 그것은 관문이 아니다.
1582
+ *
1583
+ * ⚠ 심의 초안이 제안한 "`withFileTypes` + `isDirectory()` 로 심링크를 **전부** 건너뛰기"도 채택하지
1584
+ * 않았다 — 그러면 둘째 칸이 죽어 `src/app/shared -> ../../shared` 안의 E1 2건이 `rc=1` → `rc=0` 으로
1585
+ * 뒤집힌다(실측). 크래시를 **빈 선반**으로 바꾸는 처방이다. 구역을 나누면 셋 다 산다.
1586
+ *
1587
+ * 순환은 **조상 사슬**로 끊는다(전역 집합이 아니라) — 둘째 칸을 따라간 뒤 그 안에서 되돌아오는 경우가
1588
+ * 남기 때문이다. 깊이 상한은 최후의 안전망이지 순환 차단 수단이 아니다(상한으로 버티면 같은 위반이
1589
+ * 64줄 찍힌다 — 실측). 그리고 **못 읽은 자리는 적는다** — 조용한 `catch {}` 는 관문에서 통과로 읽힌다.
1590
+ *
1591
+ * @param dir 시작 디렉터리. 없으면(ENOENT/ENOTDIR) 조용히 끝난다 — 선택적 디렉터리가 많다.
1592
+ * @param onFile 파일마다 불린다. 디렉터리 판단·순환 차단은 여기서 신경 쓸 필요가 없다.
1593
+ * @param what 못 읽었을 때 사람에게 보일 말("X1 라우트 수집" 처럼 **어느 검사가** 못 돌았는지).
1594
+ */
1595
+ function walkTree(dir, onFile, what) {
1596
+ const step = (d, depth, ancestors) => {
1597
+ if (depth > WALK_MAX_DEPTH) {
1598
+ markUnmeasured(d, "EDEPTH", `디렉터리 깊이 ${WALK_MAX_DEPTH} 초과 — ${what}`);
1599
+ return;
1600
+ }
1601
+ let names;
1602
+ try {
1603
+ names = readdirSync(d);
1604
+ } catch (e) {
1605
+ // 없는 디렉터리는 "못 잰 것"이 아니다 — 선택적 좌표가 대부분이다.
1606
+ if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(d, e.code, what);
1607
+ return;
1608
+ }
1609
+ for (const name of names) {
1610
+ const full = join(d, name);
1611
+ // **빌드가 버리는 것만 건너뛴다 — 그 밖은 전부 훑는다.**
1612
+ //
1613
+ // ⚠ 종전엔 이 셋을 **깊이 무관하게 이름으로** 건너뛰었다. 그런데 서빙 복사는 최상위만
1614
+ // 버린다(`build.sh`: `tar --exclude='./node_modules' --exclude='./.next' --exclude='./.git'`
1615
+ // — `./` 접두가 최상위에 못박는다). 즉 `src/app/node_modules/widget.tsx` 는 **빌드에 실제로
1616
+ // 실리는데 검사기만 안 봤다.** 심의 실측: 같은 E1 위반이 정상 위치에서는 관문 exit 1,
1617
+ // `node_modules` 라는 이름의 평범한 디렉터리 안에서는 exit 5(next build 까지 진행).
1618
+ // 디렉터리 이름 한 칸만 바꾸면 세 관문(서빙·납품검수·CI)이 동시에 열렸다.
1619
+ //
1620
+ // 그래서 판정을 이름이 아니라 **자리**로 한다: 레포 루트 바로 아래의 그 셋만 건너뛴다.
1621
+ // 그 아래 것은 빌드에 실리므로 검사 대상이다 — 잣대가 "번들에 들어가는가"이지
1622
+ // "이름이 무엇인가"가 아니다. (`.git` 은 비용 때문에도 뺀다 — 심의 실측 +59%.)
1623
+ if (BUILD_DROPS.has(name) && dirname(full) === repoRootDir) continue;
1624
+ let stat;
1625
+ try {
1626
+ stat = statSync(full);
1627
+ } catch (e) {
1628
+ // 끊어진 심링크·권한 없는 자리. 종전엔 여기서 **스택트레이스로 죽었다.**
1629
+ markUnmeasured(full, e.code, `파일 정보 — ${what}`);
1630
+ continue;
1631
+ }
1632
+ if (!stat.isDirectory()) {
1633
+ onFile(full, name);
1634
+ continue;
1635
+ }
1636
+ // 심링크가 아니면 realpath 를 물을 이유가 없다 — `realpathSync` 는 `statSync` 의 **11.5배**
1637
+ // 이고 **경로 깊이에 선형**이라(심의 실측: depth2 17us → depth64 281us) 디렉터리가 많은
1638
+ // 트리에서 총비용이 제곱으로 번진다. 실디렉터리는 부모의 실경로에 이름만 붙이면 된다.
1639
+ let real = join(currentReal(ancestors), name);
1640
+ if (isSymlink(full)) {
1641
+ try {
1642
+ real = realpathSync(full);
1643
+ } catch (e) {
1644
+ // ⚠ **이 줄에는 회귀 시험이 없다 — 도달 형상을 못 만들었다.** 바로 위 `statSync` 가
1645
+ // 이미 전체 경로를 해석하므로, 여기까지 왔으면 realpath 도 대개 성공한다(끊어진
1646
+ // 심링크·ELOOP·권한 부족은 전부 statSync 에서 먼저 잡힌다 — 실측). 남는 것은 훑는
1647
+ // 도중 트리가 바뀌는 경합뿐이라 시험으로 고정할 수 없다.
1648
+ // 그래서 이 줄은 **심층방어**다. 없는 커버리지를 있다고 적지 않기 위해 여기 적는다.
1649
+ markUnmeasured(full, e.code, `실경로 해석 — ${what}`);
1650
+ continue;
1651
+ }
1652
+ // ⓐ 소스 루트 안 — 따라가지 않는다. 그 실디렉터리는 제 자리에서 훑는다.
1653
+ if (within(real, root)) continue;
1654
+ // ⓑ 레포 밖 — 따라가지 않고 적는다. 못 잰 것이지 통과가 아니다.
1655
+ if (!within(real, repoRootDir)) {
1656
+ markUnmeasured(full, "EXDEV", `레포 밖을 가리키는 심링크라 따라가지 않았습니다 — ${what}`);
1657
+ continue;
1658
+ }
1659
+ }
1660
+ if (ancestors.has(real)) continue; // 조상으로 되돌아가는 순환
1661
+ step(full, depth + 1, new Set(ancestors).add(real));
1662
+ }
1663
+ };
1664
+ let rootReal;
1665
+ try {
1666
+ rootReal = realpathSync(dir);
1667
+ } catch (e) {
1668
+ // 시작 좌표의 부재는 정상(선택적 디렉터리가 많다). 그 밖의 사유는 적는다.
1669
+ if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(dir, e.code, what);
1670
+ return;
1671
+ }
1672
+ step(dir, 0, new Set([rootReal]));
1673
+ }
1674
+
1675
+ /** `p` 가 `base` 와 같거나 그 아래인가. 경로 문자열이 아니라 **경계**를 묻는다. */
1676
+ function within(p, base) {
1677
+ if (p === base) return true;
1678
+ const rel = relative(base, p);
1679
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1680
+ }
1681
+
1682
+ /** 심링크인가. `lstatSync` 는 따라가지 않는다. 못 물으면 아니라고 답한다(뒤의 `statSync` 가 잡는다). */
1683
+ function isSymlink(p) {
1684
+ try {
1685
+ return lstatSync(p).isSymbolicLink();
1686
+ } catch {
1687
+ return false;
1688
+ }
1689
+ }
1690
+
1691
+ /** 현재 하강 사슬의 끝 = 지금 디렉터리의 실경로. Set 은 삽입 순서를 지킨다. */
1692
+ function currentReal(ancestors) {
1693
+ let last;
1694
+ for (const a of ancestors) last = a;
1695
+ return last;
1696
+ }
1697
+
1313
1698
  function walk(dir) {
1314
- for (const name of readdirSync(dir)) {
1315
- if (name === "node_modules" || name === ".next") continue;
1316
- const full = join(dir, name);
1317
- if (statSync(full).isDirectory()) {
1318
- walk(full);
1699
+ walkTree(
1700
+ dir,
1701
+ (full, name) => {
1319
1702
  // ⚠ **관문 대가가 있다**(심의 실측): `.mjs` 를 훑으면서 `preview-runner --gate` 의 종료코드가
1320
1703
  // **0 → 1** 로 바뀐다(`[E3]` 예시 키 `oqsk_test_…` 를 오류층으로 잡는다). 실제 서빙 코퍼스는
1321
1704
  // rc=0 을 유지해 오늘의 폭발반경은 0 이지만, **발행이 남의 관문 계약을 바꾼다**는 사실은
@@ -1327,13 +1710,15 @@ function walk(dir) {
1327
1710
  // ⚠ `.mjs`·`.cjs` 도 훑는다. 종전엔 빠져 있어 **그 파일에서는 규칙이 아예 안 돌았다**
1328
1711
  // (심의 관찰 — 검사기가 조용한 것이지 안전한 것이 아니다). Next 설정·서버 유틸이 그 확장자로
1329
1712
  // 오는 레포가 있다.
1330
- } else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) {
1331
- check(full);
1332
- if (isLayoutFile(full)) layoutFiles.push(full);
1333
- } else if (name.endsWith(".css")) {
1334
- cssFiles.push(full);
1335
- }
1336
- }
1713
+ if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) {
1714
+ check(full);
1715
+ if (isLayoutFile(full)) layoutFiles.push(full);
1716
+ } else if (name.endsWith(".css")) {
1717
+ cssFiles.push(full);
1718
+ }
1719
+ },
1720
+ "소스 파일 수집(규약 검사 본체)",
1721
+ );
1337
1722
  }
1338
1723
 
1339
1724
  // ── C1b: layout 폭발반경 게이트 ──────────────────────────────────
@@ -1356,7 +1741,10 @@ function isLayoutFile(file) {
1356
1741
  if (!/^(layout|template)\.(ts|tsx|js|jsx)$/.test(basename(file))) return false;
1357
1742
  const segments = relative(root, file).split(sep);
1358
1743
  if (segments[0] !== "app") return false;
1359
- return !segments.slice(1, -1).map(normalizeSegment).some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
1744
+ return !segments
1745
+ .slice(1, -1)
1746
+ .map(normalizeSegment)
1747
+ .some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
1360
1748
  }
1361
1749
 
1362
1750
  /**
@@ -1393,7 +1781,32 @@ const DYNAMIC_API = [
1393
1781
  * (SiteHeader 주석이 정확히 그렇다). `//` 는 URL(`http://`)과 구분하려고 앞 문자가 `:` 이 아닐 때만.
1394
1782
  */
1395
1783
  function stripComments(src) {
1396
- return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
1784
+ // ⚠ 종전엔 `src.replace(/\/\*[\s\S]*?\*\//g, " ")` 였다. **닫히지 않은 `/*` 가 많은 파일에서
1785
+ // 2차로 폭발한다** — 오프너마다 파일 끝까지 훑고 실패한 뒤 한 칸 전진해 다시 훑는다.
1786
+ // 실측(심의 성능축 F5): 배증할 때마다 4배 — 15KB 17.6ms · 30KB 103ms · 60KB 275ms ·
1787
+ // 120KB 1320ms, 검사기 전체로는 240KB 17.6초 · 600KB 110초. 이 레포는 이미 두 번 ReDoS 를
1788
+ // 냈고 `REGEX_SCAN_CAP` 으로 옆 자리를 막아 뒀는데, **상한 없는 형제가 그대로 남아 있었다.**
1789
+ //
1790
+ // 선형 스캐너로 바꾸되 **의미는 종전과 같다**: 닫는 `*/` 가 없으면 그 `/*` 는 주석으로 치지
1791
+ // 않고 원문을 남긴다(정규식이 닫힘을 요구했으므로 같은 결과다). 뒤에 `*/` 가 하나도 없으면
1792
+ // 그 뒤의 어떤 `/*` 도 닫힐 수 없으므로 거기서 멈추는 것이 안전하다.
1793
+ let out = "";
1794
+ let i = 0;
1795
+ for (;;) {
1796
+ const open = src.indexOf("/*", i);
1797
+ if (open === -1) {
1798
+ out += src.slice(i);
1799
+ break;
1800
+ }
1801
+ const close = src.indexOf("*/", open + 2);
1802
+ if (close === -1) {
1803
+ out += src.slice(i); // 미종료 — 원문 유지(종전 정규식과 동일)
1804
+ break;
1805
+ }
1806
+ out += src.slice(i, open) + " ";
1807
+ i = close + 2;
1808
+ }
1809
+ return out.replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
1397
1810
  }
1398
1811
 
1399
1812
  /**
@@ -1532,7 +1945,9 @@ function checkLayoutBlastRadius() {
1532
1945
 
1533
1946
  // 면제는 **범인 파일**에 붙인다 — layout 에 붙이면 그 아래 전부가 한 번에 뚫린다.
1534
1947
  // 신 마커 zalkera- + 구 마커(oneq-/oneque-)를 양형 수용한다(리네임 이행기).
1535
- const allow = readFileSync(file, "utf8").match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
1948
+ const allow = (readSafe(file, "C1b 면제 마커 판독") ?? "").match(
1949
+ /\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/,
1950
+ );
1536
1951
  const path = chain.map((f) => relative(process.cwd(), f)).join(" → ");
1537
1952
  const detail =
1538
1953
  `${relative(process.cwd(), layout)} 이 ${why} 에 도달한다 → ${path}. ` +
@@ -1574,12 +1989,10 @@ function checkThemeWiring() {
1574
1989
  if (STYLE_MODE !== "declared") return; // 선언 없는 레포의 주입 부재는 결함이 아니라 정상이다.
1575
1990
 
1576
1991
  const globalsCss = join(root, "app", "globals.css");
1577
- let css = null;
1578
- try {
1579
- css = readFileSync(globalsCss, "utf8");
1580
- } catch {
1581
- return; // 파일 부재는 S3 가 이미 error 로 잡는다 — 같은 사실을 두 번 외치지 않는다.
1582
- }
1992
+ // 부재는 S3 가 이미 error 로 잡는다(같은 사실을 두 번 외치지 않는다). 그러나 **있는데 못 읽는 것**은
1993
+ // 다른 사실이고, 조용히 return 하면 S8 이 통째로 사라진 채 ✅ 가 난다(심의 실측).
1994
+ const css = readSafe(globalsCss, "S8 테마 토큰 검사(globals.css)");
1995
+ if (css === null) return;
1583
1996
 
1584
1997
  // S8-a — 토큰 정의.
1585
1998
  if (!/@theme\b/.test(css) || !/--color-primary\s*:/.test(css)) {
@@ -1601,7 +2014,8 @@ function checkThemeWiring() {
1601
2014
  });
1602
2015
  if (!rootLayout) return; // root layout 부재는 C1 계열의 몫.
1603
2016
 
1604
- const raw = readFileSync(rootLayout, "utf8");
2017
+ const raw = readSafe(rootLayout, "테마 주입 배선 검사(root layout)");
2018
+ if (raw === null) return;
1605
2019
  const src = stripComments(raw); // 주석은 거짓말을 한다 — 앵커를 코드에서만 찾는다.
1606
2020
  const injects = /parseThemeColors\s*\(/.test(src) && /<html[^>]*\sstyle=/.test(src);
1607
2021
  if (injects) return;
@@ -1628,12 +2042,9 @@ function checkSectionCoverage() {
1628
2042
  if (!Array.isArray(contract) || contract.length === 0) return;
1629
2043
 
1630
2044
  const rendererPath = join(root, "components/sections/SectionRenderer.tsx");
1631
- let src;
1632
- try {
1633
- src = readFileSync(rendererPath, "utf8");
1634
- } catch {
1635
- return; // 렌더러가 없는 구조(BYO) — 검사 대상 아님.
1636
- }
2045
+ // 렌더러가 없는 구조(BYO)는 검사 대상이 아니다. 못 읽는 것은 그것과 다른 사실이다.
2046
+ const src = readSafe(rendererPath, "섹션 렌더러 계약 검사");
2047
+ if (src === null) return;
1637
2048
  const cases = new Set([...src.matchAll(/case\s+"([A-Z_]+)"/g)].map((m) => m[1]));
1638
2049
  const missing = contract.map((c) => c.type).filter((t) => !cases.has(t));
1639
2050
  if (missing.length > 0) {
@@ -1724,16 +2135,16 @@ function checkContentContract() {
1724
2135
  const sink = contentSink();
1725
2136
  if (!sink) return;
1726
2137
 
1727
- const repoRoot = resolve(root, "..");
2138
+ const repoRoot = repoRootDir;
1728
2139
  const rel = (p) => relative(process.cwd(), p);
1729
2140
  const files = contentPageFiles(repoRoot);
1730
2141
 
1731
2142
  // N1 — 매니페스트. 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다(계약 rev 4 `contentFile.manifest`).
1732
2143
  const manifestPath = join(repoRoot, "content", "index.ts");
1733
- let manifest = null;
1734
- try {
1735
- manifest = readFileSync(manifestPath, "utf8");
1736
- } catch {
2144
+ // 종전엔 EACCES 도 "없습니다"로 보고했다 — 있는데 못 읽는 것을 부재로 말하면 고칠 자리가 어긋난다.
2145
+ const manifest = readSafe(manifestPath, "N1 콘텐츠 매니페스트");
2146
+ if (manifest === null && existsSync(manifestPath)) return; // 못 읽음은 markUnmeasured 가 적었다
2147
+ if (manifest === null) {
1737
2148
  sink.push(
1738
2149
  `[N1] ${rel(manifestPath)} 가 없습니다 — 콘텐츠 매니페스트(정적 import)가 없으면 ` +
1739
2150
  `dev 에서 json 을 고쳐도 화면이 안 바뀌고(HMR 미발화), 빌드 산출물에 콘텐츠가 안 실립니다.`,
@@ -1745,9 +2156,7 @@ function checkContentContract() {
1745
2156
  // import 로 읽으면 없는 파일을 찾는 오탐이 난다(실제로 이 검사를 넣자마자 그렇게 죽었다).
1746
2157
  // 이 레포가 C1b·S8 에서 이미 밟은 함정과 같은 것이라 같은 처방을 쓴다.
1747
2158
  const declaredSlugs = manifest
1748
- ? new Set(
1749
- [...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([\w-]+)\.json["']/g)].map((m) => m[1]),
1750
- )
2159
+ ? new Set([...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([\w-]+)\.json["']/g)].map((m) => m[1]))
1751
2160
  : null;
1752
2161
  if (declaredSlugs) {
1753
2162
  for (const file of files) {
@@ -1761,7 +2170,9 @@ function checkContentContract() {
1761
2170
  }
1762
2171
  for (const slug of declaredSlugs) {
1763
2172
  if (!files.some((f) => basename(f, ".json") === slug)) {
1764
- sink.push(`[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`);
2173
+ sink.push(
2174
+ `[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`,
2175
+ );
1765
2176
  }
1766
2177
  }
1767
2178
  }
@@ -1770,15 +2181,19 @@ function checkContentContract() {
1770
2181
  warnIfContractMissing(contract);
1771
2182
 
1772
2183
  for (const file of files) {
2184
+ const pageText = readSafe(file, "N2 콘텐츠 페이지 판독");
2185
+ if (pageText === null) continue; // 못 읽음은 파싱 실패가 아니다
1773
2186
  let page;
1774
2187
  try {
1775
- page = JSON.parse(readFileSync(file, "utf8"));
2188
+ page = JSON.parse(pageText);
1776
2189
  } catch (e) {
1777
2190
  sink.push(`[N2] ${rel(file)}: JSON 파싱 실패 — ${e.message}`);
1778
2191
  continue;
1779
2192
  }
1780
2193
  if (page == null || typeof page !== "object" || Array.isArray(page)) {
1781
- sink.push(`[N2] ${rel(file)}: 최상위가 객체여야 합니다(현재 ${Array.isArray(page) ? "배열" : typeof page}).`);
2194
+ sink.push(
2195
+ `[N2] ${rel(file)}: 최상위가 객체여야 합니다(현재 ${Array.isArray(page) ? "배열" : typeof page}).`,
2196
+ );
1782
2197
  continue;
1783
2198
  }
1784
2199
 
@@ -1795,7 +2210,9 @@ function checkContentContract() {
1795
2210
  continue;
1796
2211
  }
1797
2212
  if (typeof section.type !== "string" || section.type.trim() === "") {
1798
- sink.push(`[N4] ${at}: type 은 비어 있지 않은 문자열이어야 합니다 — 렌더러가 이 섹션을 통째로 건너뜁니다.`);
2213
+ sink.push(
2214
+ `[N4] ${at}: type 은 비어 있지 않은 문자열이어야 합니다 — 렌더러가 이 섹션을 통째로 건너뜁니다.`,
2215
+ );
1799
2216
  continue;
1800
2217
  }
1801
2218
  if ("sortOrder" in section) {
@@ -1831,7 +2248,12 @@ function checkContentContract() {
1831
2248
  sink.push(`[N5] ${at}: "${path}" 는 public 루트 절대 경로 문자열이어야 합니다.`);
1832
2249
  continue;
1833
2250
  }
1834
- if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.split("/").includes("..")) {
2251
+ if (
2252
+ !value.startsWith("/") ||
2253
+ value.startsWith("//") ||
2254
+ value.includes("\\") ||
2255
+ value.split("/").includes("..")
2256
+ ) {
1835
2257
  sink.push(
1836
2258
  `[N5] ${at}: "${path}" = ${JSON.stringify(value)} — 레포 public/ 루트 절대 경로만 그려집니다` +
1837
2259
  `(원격 URL·상대 경로·경로 탈출은 렌더에서 통째로 떨어집니다).`,
@@ -1841,7 +2263,9 @@ function checkContentContract() {
1841
2263
  try {
1842
2264
  statSync(join(repoRoot, "public", value));
1843
2265
  } catch {
1844
- sink.push(`[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`);
2266
+ sink.push(
2267
+ `[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`,
2268
+ );
1845
2269
  }
1846
2270
  }
1847
2271
  // N5 — 계약 필수 참조.
@@ -1878,7 +2302,15 @@ function checkContentContract() {
1878
2302
  }
1879
2303
 
1880
2304
  function check(file) {
1881
- const src = readFileSync(file, "utf8");
2305
+ let src;
2306
+ try {
2307
+ src = readFileSync(file, "utf8");
2308
+ } catch (e) {
2309
+ // 권한 없는 파일. 종전엔 여기서 스택트레이스로 죽었다(심의 처방이 못 닫은 자리 — 그 처방은
2310
+ // `readdirSync` 축만 봤다).
2311
+ markUnmeasured(file, e.code, "파일 읽기");
2312
+ return;
2313
+ }
1882
2314
  const rel = relative(process.cwd(), file);
1883
2315
  // ⚠ **파일 머리만 본다(심의 차단 4).** 종전은 `/m` 플래그로 **원문 전체의 행머리**를 봤고,
1884
2316
  // 행머리에 `"use client"` 를 담은 **템플릿 리터럴 한 줄**이 있으면 SEO 페이지가 클라이언트로
@@ -1891,14 +2323,17 @@ function check(file) {
1891
2323
  // 서버 클라이언트 싱글턴 존재 확인 — create{Zalkera,Oneque}Client 호출(구 심볼 수용).
1892
2324
  if (/create(?:Zalkera|Oneque)Client\s*\(/.test(src)) {
1893
2325
  singletonFound = true;
1894
- if (isClient) servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
2326
+ if (isClient)
2327
+ servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
1895
2328
  }
1896
2329
 
1897
2330
  if (isClient) {
1898
2331
  // E1: 값 import(= import type 아님)로 @zalkera/client 를 들여옴(구 @oneque/client 도 잡는다).
1899
2332
  const valueImport = /^import\s+(?!type\s)[^;]*from\s+["']@(?:zalkera|oneque)\/client["']/m.test(src);
1900
2333
  if (valueImport) {
1901
- servingSink().push(`[E1] ${rel}: "use client" 파일에서 @zalkera/client 를 값으로 import 한다 (타입은 \`import type\` 으로).`);
2334
+ servingSink().push(
2335
+ `[E1] ${rel}: "use client" 파일에서 @zalkera/client 를 값으로 import 한다 (타입은 \`import type\` 으로).`,
2336
+ );
1902
2337
  }
1903
2338
  // E2: 서버 싱글턴(lib/zalkera) import(구 lib/oneque 도 잡는다).
1904
2339
  if (/from\s+["'][^"']*lib\/(?:zalkera|oneque)["']/.test(src)) {
@@ -2049,8 +2484,9 @@ function checkStyleWiring() {
2049
2484
  }
2050
2485
  });
2051
2486
  if (rootLayout) {
2052
- const src = stripComments(readFileSync(rootLayout, "utf8"));
2053
- if (!/import\s+["'][^"']*globals\.css["']/.test(src)) {
2487
+ const layoutText = readSafe(rootLayout, "S3 globals.css import 검사");
2488
+ const src = stripComments(layoutText ?? "");
2489
+ if (layoutText !== null && !/import\s+["'][^"']*globals\.css["']/.test(src)) {
2054
2490
  s3.push(
2055
2491
  `[S3] ${relative(process.cwd(), rootLayout)} 이 globals.css 를 import 하지 않습니다 ` +
2056
2492
  `(\`import "./globals.css"\`). 배선이 없으면 Tailwind CSS·테마 토큰이 로드되지 않습니다.`,
@@ -2134,7 +2570,11 @@ function stripLiterals(code, jsx = false) {
2134
2570
  }
2135
2571
  if (c === "/" && code[i + 1] === "*") {
2136
2572
  const start = i;
2137
- i = code.indexOf("*/", i);
2573
+ // ⚠ **오프너 다음부터** 찾는다. `i` 에서 찾으면 `/*/api/…` 의 앞 두 글자(`*` + `/`)
2574
+ // 가짜 종료가 돼 주석이 즉시 닫힌 것으로 읽힌다 — 뒤따르는 진짜 코드가 주석으로 남아
2575
+ // **가드 없는 POST 에 X1 이 완전히 침묵**했다(심의 실측 · 퍼징 40,000회에서 두 사본이
2576
+ // 1,161건 갈렸고 이 수정으로 0건). 두 사본 비대칭의 유일한 원인이었다.
2577
+ i = code.indexOf("*/", i + 2);
2138
2578
  if (i < 0) i = code.length;
2139
2579
  else i += 1;
2140
2580
  blank(start, i + 1);
@@ -2265,7 +2705,11 @@ function findMutationHandlers(code) {
2265
2705
  // ⒞ `export {POST}` · `export {handler as POST}` — 본문을 못 따라간다.
2266
2706
  for (const m of code.matchAll(/export\s*\{([^}]*)\}/g)) {
2267
2707
  for (const part of m[1].split(",")) {
2268
- const name = part.trim().split(/\s+as\s+/).pop()?.trim();
2708
+ const name = part
2709
+ .trim()
2710
+ .split(/\s+as\s+/)
2711
+ .pop()
2712
+ ?.trim();
2269
2713
  if (name && new RegExp(`^(${METHODS})$`).test(name)) push(name, null);
2270
2714
  }
2271
2715
  }
@@ -2354,22 +2798,17 @@ function judgeGuardPlacement(rawBody) {
2354
2798
  // 로 쓰면 플랫폼 존에서 형제 테넌트가 통과한다.
2355
2799
  function checkCrossOriginGuards() {
2356
2800
  const routes = [];
2357
- const collect = (dir) => {
2358
- let entries;
2359
- try {
2360
- entries = readdirSync(dir, {withFileTypes: true});
2361
- } catch {
2362
- return;
2363
- }
2364
- for (const e of entries) {
2365
- const full = join(dir, e.name);
2366
- if (e.isDirectory()) collect(full);
2367
- // ⚠ `route.js`·`.jsx`·`.mjs` 도 라우트다. 종전엔 `.ts`·`.tsx` 만 봐서 **가드 규칙(X1)
2368
- // 그 파일들에서 통째로 침묵**했다(I2 는 도는데 X1 만 안 도는 비대칭 · 심의 실측).
2369
- // 구식인 Pages Router 쪽(X1p)이 오히려 `.js` 를 보고 있었다.
2370
- else if (/^route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e.name)) routes.push(full);
2371
- }
2372
- };
2801
+ const collect = (dir) =>
2802
+ walkTree(
2803
+ dir,
2804
+ (full, name) => {
2805
+ // `route.js`·`.jsx`·`.mjs` 도 라우트다. 종전엔 `.ts`·`.tsx` 만 봐서 **가드 규칙(X1)이
2806
+ // 그 파일들에서 통째로 침묵**했다(I2 는 도는데 X1 만 안 도는 비대칭 · 심의 실측).
2807
+ // 구식인 Pages Router 쪽(X1p)이 오히려 `.js` 를 보고 있었다.
2808
+ if (/^route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) routes.push(full);
2809
+ },
2810
+ "X1 라우트 수집(교차출처 가드)",
2811
+ );
2373
2812
  // ⚠ `root` 는 **레포 루트가 아니라 소스 루트**(`./src`)다(82행). 초판은 `src/app/api` 도
2374
2813
  // 함께 걸었는데 그건 `./src/src/app/api` 라 존재하지 않는 죽은 경로였다.
2375
2814
  //
@@ -2381,12 +2820,13 @@ function checkCrossOriginGuards() {
2381
2820
 
2382
2821
  const exempted = [];
2383
2822
  for (const file of routes) {
2384
- const code = readFileSync(file, "utf8");
2823
+ const code = readSafe(file, "X1 교차출처 가드 검사");
2824
+ if (code === null) continue;
2385
2825
  const rel = relative(root, file);
2386
2826
  // ⚠ **가드 축(X1·X1p)만 JSX 강등을 끈다.** JSX 파일에서 낱말 뒤 정규식을 나눗셈으로 두면
2387
- // 그 안의 백틱이 살아나 파일을 삼켜 **가드가 안 보인다**(미탐). 산문 삼킴은 I2·X3 축에만
2388
- // 해로우므로, 두 축을 갈라 각각에 맞는 사본을 준다(심의 처방 · 미탐 4·오탐 2 동시 해소).
2389
- const handlers = findMutationHandlers(stripLiterals(code, false));
2827
+ // 그 안의 백틱이 살아나 파일을 삼켜 **가드가 안 보인다**(미탐). 산문 삼킴은 I2·X3 축에만
2828
+ // 해로우므로, 두 축을 갈라 각각에 맞는 사본을 준다(심의 처방 · 미탐 4·오탐 2 동시 해소).
2829
+ const handlers = findMutationHandlers(stripLiterals(code, false));
2390
2830
  if (!handlers.length) continue;
2391
2831
 
2392
2832
  // 면제 마커는 **파일 상단**에만 둔다 — 아무 데나 허용하면 주석·문자열 안의 한 줄로
@@ -2421,7 +2861,8 @@ function checkCrossOriginGuards() {
2421
2861
 
2422
2862
  // X2 — 읽기 GET 면제는 "CORS 헤더가 없다"에 의존한다. 그 전제가 깨지면 면제도 깨진다.
2423
2863
  for (const file of routes) {
2424
- const code = readFileSync(file, "utf8");
2864
+ const code = readSafe(file, "X2 CORS 헤더 검사");
2865
+ if (code === null) continue;
2425
2866
  if (/Access-Control-Allow-Origin/i.test(code)) {
2426
2867
  crossOriginSink().push(
2427
2868
  `[X2] ${relative(root, file)} 가 CORS 헤더를 답니다 — 읽기 GET 을 가드에서 빼는 근거가` +
@@ -2440,25 +2881,19 @@ function checkCrossOriginGuards() {
2440
2881
  // **fail-open** 했다(심의 실측: 소각 삭제 + 리네임 = 통과). `./src/src/app/api` 이후
2441
2882
  // 좌표가 죽어 검사가 사라진 **세 번째 사례**라 정의를 찾아가는 쪽으로 바꾼다.
2442
2883
  const sources = [];
2443
- const collectSources = (dir) => {
2444
- let entries;
2445
- try {
2446
- entries = readdirSync(dir, {withFileTypes: true});
2447
- } catch {
2448
- return;
2449
- }
2450
- for (const e of entries) {
2451
- const full = join(dir, e.name);
2452
- if (e.isDirectory()) collectSources(full);
2453
- else if (/\.tsx?$/.test(e.name)) sources.push(full);
2454
- }
2455
- };
2456
- collectSources(root);
2884
+ walkTree(
2885
+ root,
2886
+ (full, name) => {
2887
+ if (/\.tsx?$/.test(name)) sources.push(full);
2888
+ },
2889
+ "X3 소스 수집(OAuth state 소각)",
2890
+ );
2457
2891
 
2458
2892
  let consumeDef = null;
2459
2893
  let usesStateCookie = false;
2460
2894
  for (const file of sources) {
2461
- const raw = readFileSync(file, "utf8");
2895
+ const raw = readSafe(file, "파일 읽기");
2896
+ if (raw === null) continue;
2462
2897
  const src = stripLiterals(raw, holdsJsx(file, raw)); // X3 — 산문 삼킴을 막아야 하는 축
2463
2898
  if (/\bconsumeOAuthState\s*\(/.test(src)) usesStateCookie = true;
2464
2899
  // 정의 형태를 묻지 않는다 — function 선언·화살표 상수 둘 다.
@@ -2537,7 +2972,7 @@ function checkCrossOriginGuards() {
2537
2972
 
2538
2973
  /** Pages Router 루트(`<src>/pages` 또는 레포 루트 `pages`). 없으면 null — 부재는 결함이 아니다. */
2539
2974
  function pagesRoot() {
2540
- for (const cand of [join(root, "pages"), join(resolve(root, ".."), "pages")]) {
2975
+ for (const cand of [join(root, "pages"), join(repoRootDir, "pages")]) {
2541
2976
  try {
2542
2977
  if (statSync(cand).isDirectory()) return cand;
2543
2978
  } catch {
@@ -2547,20 +2982,15 @@ function pagesRoot() {
2547
2982
  return null;
2548
2983
  }
2549
2984
 
2550
- /** 디렉터리 아래 소스 파일 전량(재귀). `node_modules`·`.next` 건너뛴다. */
2985
+ /** 디렉터리 아래 소스 파일 전량(재귀). 걷는 규율은 [walkTree] 하나에 모여 있다. */
2551
2986
  function collectSourceFiles(dir, into = []) {
2552
- let entries;
2553
- try {
2554
- entries = readdirSync(dir, {withFileTypes: true});
2555
- } catch {
2556
- return into;
2557
- }
2558
- for (const e of entries) {
2559
- if (e.name === "node_modules" || e.name === ".next") continue;
2560
- const full = join(dir, e.name);
2561
- if (e.isDirectory()) collectSourceFiles(full, into);
2562
- else if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name)) into.push(full);
2563
- }
2987
+ walkTree(
2988
+ dir,
2989
+ (full, name) => {
2990
+ if (/\.(ts|tsx|js|jsx|mjs)$/.test(name)) into.push(full);
2991
+ },
2992
+ "C1p·X1p 소스 수집(Pages Router 축)",
2993
+ );
2564
2994
  return into;
2565
2995
  }
2566
2996
 
@@ -2582,7 +3012,8 @@ function checkPagesRouter() {
2582
3012
 
2583
3013
  for (const file of collectSourceFiles(pagesDir)) {
2584
3014
  const rel = relative(process.cwd(), file);
2585
- const raw = readFileSync(file, "utf8");
3015
+ const raw = readSafe(file, "파일 읽기");
3016
+ if (raw === null) continue;
2586
3017
  const text = stripComments(raw);
2587
3018
  const stem = basename(file).replace(/\.[^.]+$/, "");
2588
3019
 
@@ -2656,20 +3087,22 @@ function checkPagesRouter() {
2656
3087
  * 값은 안 본다(형태만 본다) — 검사기가 시크릿 값을 읽어 출력에 실으면 그게 또 하나의 유출이다.
2657
3088
  */
2658
3089
  function checkEnvFiles() {
2659
- const repoRoot = resolve(root, "..");
3090
+ const repoRoot = repoRootDir;
2660
3091
  let names;
2661
3092
  try {
2662
3093
  names = readdirSync(repoRoot).filter((n) => n === ".env" || n.startsWith(".env."));
2663
- } catch {
3094
+ } catch (e) {
3095
+ // 레포 루트를 못 읽으면 E3 가 **한 줄도 안 돈다.** 조용히 return 하면 ✅ 가 난다.
3096
+ if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(repoRoot, e.code, "E3 환경파일 스캔");
2664
3097
  return;
2665
3098
  }
2666
3099
  for (const name of names.sort()) {
2667
- let lines;
2668
- try {
2669
- lines = readFileSync(join(repoRoot, name), "utf8").split("\n");
2670
- } catch {
2671
- continue;
2672
- }
3100
+ // ⚠ 여기가 E3 의 **다른 절반**이다. 디렉터리는 읽히는데 파일이 안 읽히는 경우가 있고, 종전엔
3101
+ // 그때 조용히 `continue` 해서 **시크릿을 한 줄도 안 보고 `✅ 통과`** 가 났다(심의 셋이 모두 지목).
3102
+ // 디렉터리 읽기만 고치고 파일 읽기를 남겨 두면 같은 구멍이 그대로다 — 실제로 한 번 그랬다.
3103
+ const text = readSafe(join(repoRoot, name), `E3 환경파일 판독(${name})`);
3104
+ if (text === null) continue;
3105
+ const lines = text.split("\n");
2673
3106
  for (const line of lines) {
2674
3107
  if (/^\s*#/.test(line)) continue; // 주석 — `.env.example` 이 금지 규약을 문장으로 적어 뒀다.
2675
3108
  const m = line.match(PUBLIC_SECRET_ENV);
@@ -2719,17 +3152,14 @@ function isStaticallyReadableConfig(text) {
2719
3152
  }
2720
3153
 
2721
3154
  function checkServingOutputContract() {
2722
- const repoRoot = resolve(root, "..");
3155
+ const repoRoot = repoRootDir;
2723
3156
  const found = NEXT_CONFIG_NAMES.map((n) => join(repoRoot, n)).filter((p) => existsSync(p));
2724
3157
  // 설정 파일이 여럿이면 어느 것이 유효한지 Next 의 해석 순서에 달렸다 — 못 가르는 자리라 스킵한다.
2725
3158
  if (found.length !== 1) return;
2726
3159
 
2727
- let text;
2728
- try {
2729
- text = stripComments(readFileSync(found[0], "utf8"));
2730
- } catch {
2731
- return;
2732
- }
3160
+ const configText = readSafe(found[0], "O1 서빙 산출물 계약 검사(next.config)");
3161
+ if (configText === null) return;
3162
+ const text = stripComments(configText);
2733
3163
  if (!isStaticallyReadableConfig(text)) return; // 동적 조립 — 잠자코 스킵.
2734
3164
 
2735
3165
  const name = basename(found[0]);
@@ -2753,8 +3183,16 @@ function checkServingOutputContract() {
2753
3183
  servingOutputSink().push(`[O1] ${name}: output 설정이 없습니다 — ${advice}`);
2754
3184
  }
2755
3185
 
3186
+ // ⚠ `statSync` 만으로는 **파일을 소스 루트로 준 경우**를 못 가른다. `walkTree` 는 시작 좌표의
3187
+ // ENOTDIR 를 "선택적 하위 디렉터리 부재"로 관용하므로, 그대로 두면 훑을 것이 0개인 채 `✅ rc=0` 으로
3188
+ // 끝난다(심의 실측 — 구판은 크래시로나마 관문을 닫았다). 선택은 하위 좌표의 성질이지 시작 좌표의
3189
+ // 성질이 아니다. 여기서 틀리면 **검사를 한 줄도 안 돌고 통과**가 난다.
2756
3190
  try {
2757
- statSync(root);
3191
+ if (!statSync(root).isDirectory()) {
3192
+ console.error(`디렉터리가 아닙니다(소스 디렉터리를 주십시오 · 보통 ./src): ${root}`);
3193
+ console.error(`검사를 시작하지 못했습니다 — 통과가 아닙니다.`);
3194
+ process.exit(2);
3195
+ }
2758
3196
  } catch {
2759
3197
  console.error(`디렉터리를 찾을 수 없습니다: ${root}`);
2760
3198
  process.exit(2);
@@ -2785,7 +3223,7 @@ checkServingOutputContract(); // O1 — 서빙 산출물 계약(경고 전용·
2785
3223
  function checkManualCarrier() {
2786
3224
  // ⚠ **절대 경로여야 한다.** 초판이 `join(root, "..")` 상대 경로를 `createRequire` 에 넘겨 조용히
2787
3225
  // catch 로 빠졌고, 사본을 훼손해도 경고가 안 났다(변이 실측). 있는데 안 도는 검사기가 없는 것보다 나쁘다.
2788
- const projectRoot = resolve(root, "..");
3226
+ const projectRoot = repoRootDir;
2789
3227
  const at = join(projectRoot, "llms.txt");
2790
3228
  if (!existsSync(at)) return; // 없으면 스킵 — 지운 것은 자유.
2791
3229
  let carried;
@@ -2807,7 +3245,8 @@ function checkManualCarrier() {
2807
3245
 
2808
3246
  checkManualCarrier(); // W-LLMS — zip 이 나르는 명세가 설치본과 같은가(fail-soft).
2809
3247
 
2810
- if (!singletonFound) warnings.push(`[W1] createZalkeraClient 싱글턴을 찾지 못했습니다 — 서버 사이드 호출 패턴이 있는지 확인하세요.`);
3248
+ if (!singletonFound)
3249
+ warnings.push(`[W1] createZalkeraClient 싱글턴을 찾지 못했습니다 — 서버 사이드 호출 패턴이 있는지 확인하세요.`);
2811
3250
 
2812
3251
  console.log(
2813
3252
  `스타일 규약 모드: ${STYLE_MODE}` +
@@ -2828,8 +3267,50 @@ console.log(
2828
3267
  for (const w of warnings) console.warn("⚠️ " + w);
2829
3268
  for (const e of errors) console.error("❌ " + e);
2830
3269
 
3270
+ // ── 못 잰 것 — 통과도 위반도 아닌 셋째 칸 ───────────────────────────────
3271
+ //
3272
+ // 문면은 `build.sh` §exit4 의 2층 관례를 그대로 쓴다: 첫 줄은 **업로더가 할 수 있는 일**,
3273
+ // 다음 줄은 개발자·LLM 에 그대로 붙여넣을 지시. 종전엔 이 자리가 스택트레이스이거나 침묵이었다.
3274
+ // 같은 파일이 여러 검사에서 걸리면 줄이 그만큼 늘어난다 — 사람에게는 **자리 수**가 정보다.
3275
+ // 자리별로 접고, 어느 검사가 사라졌는지는 그 아래에 모은다.
3276
+ const unmeasuredByPath = new Map();
3277
+ for (const u of unmeasured) {
3278
+ const key = `${u.path}|${u.code}`;
3279
+ if (!unmeasuredByPath.has(key)) unmeasuredByPath.set(key, {...u, whats: new Set()});
3280
+ unmeasuredByPath.get(key).whats.add(u.what);
3281
+ }
3282
+
3283
+ if (unmeasuredByPath.size > 0) {
3284
+ const say = GATE_MODE ? console.error : console.warn;
3285
+ const mark = GATE_MODE ? "❌" : "⚠️ ";
3286
+ say(`\n${mark} 읽지 못한 자리 ${unmeasuredByPath.size}곳 — 이 자리들은 **검사하지 않았습니다(통과가 아닙니다).**`);
3287
+ say(` 이 소스를 만든 개발자에게 아래 줄을 그대로 전달하십시오.`);
3288
+ for (const u of unmeasuredByPath.values()) {
3289
+ say(` · ${u.path} [${u.code}] — 못 돈 검사: ${[...u.whats].join(" · ")}`);
3290
+ }
3291
+ if (GATE_MODE) {
3292
+ // 관문 fail-closed. 오탐 비용을 0 이라고 적었던 종전 주석은 **거짓이었다**(심의 실측): 서빙
3293
+ // 복사는 `tar --no-same-owner` 라 소유권만 정규화하고 **모드는 보존**하며, 빌더는 `--cap-drop ALL`
3294
+ // 이라 root 라도 DAC_OVERRIDE 가 없어 EACCES 가 실제로 난다. 그래도 닫는 것이 맞다 — 이유가
3295
+ // "안 뜬다"가 아니라 **"못 잰 것을 통과로 세지 않는다"** 이기 때문이다.
3296
+ console.error(`\n검사를 끝내지 못했습니다. rc=7(검사 불능) — 규약 위반(rc=1)과 다른 뜻입니다.`);
3297
+ process.exit(7);
3298
+ }
3299
+ }
3300
+
2831
3301
  if (errors.length > 0) {
2832
3302
  console.error(`\n${errors.length}개 오류. 스토어프론트 규약 위반을 고치세요 (llms.txt §5 참고).`);
2833
3303
  process.exit(1);
2834
3304
  }
2835
- console.log(`✅ 통과 검사한 규약 위반 없음${warnings.length ? ` (경고 ${warnings.length})` : ""}.`);
3305
+ // **못 것을 요약 줄에 합친다.** 권고 모드에서는 위의 고지 **뒤에** 이 줄이 찍히므로, 여기서
3306
+ // 침묵하면 사람이 마지막에 본 `✅ 통과` 만 기억한다(심의 지적 — verify-zip 커밋이 스스로 적은
3307
+ // "사람은 ✅ 를 기억한다"의 더 나쁜 판이다. 거기서는 ✅ 가 위에 있었고 여기서는 아래에 있다).
3308
+ const tally = [
3309
+ warnings.length ? `경고 ${warnings.length}` : "",
3310
+ unmeasuredByPath.size ? `못 잰 자리 ${unmeasuredByPath.size}` : "",
3311
+ ]
3312
+ .filter(Boolean)
3313
+ .join(" · ");
3314
+ console.log(
3315
+ `✅ ${unmeasuredByPath.size ? "잰 범위에서는 통과" : "통과"} — 검사한 규약 위반 없음${tally ? ` (${tally})` : ""}.`,
3316
+ );