@zalkera/client 0.21.2 → 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,6 +151,69 @@ 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
 
156
219
  /**
@@ -175,9 +238,18 @@ const repoRootDir = resolveRepoRoot(root);
175
238
  * 주의: 우리 `muted` 는 **글자색**이라 `bg-muted`(shadcn 은 배경)와 의미가 다르다.
176
239
  */
177
240
  const FOREIGN_TOKEN_CLASSES = [
178
- "bg-card", "bg-popover", "bg-muted", "bg-accent", "bg-destructive",
179
- "text-card-foreground", "text-popover-foreground", "text-muted-foreground", "text-accent-foreground",
180
- "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",
181
253
  ];
182
254
 
183
255
  /*
@@ -199,8 +271,21 @@ const FOREIGN_TOKEN_CLASSES = [
199
271
  * 검정이고 브랜드 색 표면이 아니다. 넣으면 정상 코드가 대량으로 빨개진다. 즉 이 축은 **안 잰다**.
200
272
  */
201
273
  const COLOR_UTILITY_ROOTS = new Set([
202
- "bg", "text", "border", "ring", "outline", "decoration", "divide",
203
- "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",
204
289
  ]);
205
290
  /** 색 함수. `color-mix(` 까지 — Tailwind 임의값 안에서 전부 유효하다. */
206
291
  const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\s*\(/;
@@ -213,13 +298,65 @@ const HEX_COLOR = /#[0-9a-fA-F]{3,8}(?![0-9a-zA-Z])/;
213
298
  * 아니라 위임이라 일부러 뺐다(Tailwind 에 `bg-transparent`·`text-current` 가 이미 있다).
214
299
  */
215
300
  const NAMED_COLORS = new Set([
216
- "red", "blue", "green", "black", "white", "gray", "grey", "orange", "purple", "pink", "yellow",
217
- "brown", "cyan", "magenta", "navy", "teal", "olive", "maroon", "lime", "aqua", "silver", "gold",
218
- "indigo", "violet", "crimson", "coral", "salmon", "tomato", "khaki", "beige", "ivory", "azure",
219
- "plum", "orchid", "turquoise", "lavender", "tan", "wheat", "snow", "skyblue", "hotpink",
220
- "darkblue", "darkred", "darkgreen", "lightblue", "lightgray", "lightgrey", "midnightblue",
221
- "steelblue", "slategray", "slategrey", "firebrick", "forestgreen", "seagreen", "royalblue",
222
- "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",
223
360
  ]);
224
361
 
225
362
  /**
@@ -316,7 +453,11 @@ function detectStyleMode(srcDir) {
316
453
  const pkgPath = join(dir, "package.json");
317
454
  try {
318
455
  if (statSync(pkgPath).isFile()) {
319
- 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);
320
461
  const styling = pkg?.zalkera?.styling ?? pkg?.oneque?.styling;
321
462
  if (styling === "tailwind-tokens") return "declared";
322
463
  if (styling !== undefined) return "none"; // 검사 규칙 없는 다른 선언값 = 잘커라 스타일 규약 없음
@@ -396,6 +537,25 @@ function servingSink() {
396
537
  * 반면 `E`(시크릿이 브라우저 번들에 실림)·`C`(SEO 라우트가 동적 SSR 강제)는 **사실을 잰다** — 오탐 여지가
397
538
  * 없고 상용 실측도 0건이라, 관문으로 켜도 아무도 안 막힌다. 그래서 그 둘만 `servingSink` 를 쓴다.
398
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
+ */
399
559
  function crossOriginSink() {
400
560
  return warnings;
401
561
  }
@@ -470,9 +630,9 @@ function servingOutputSink() {
470
630
  // ⚠ 아래 조각들은 **일반 문자열**로 쓴다. 정규식 리터럴·템플릿 리터럴 안에 백틱을 담을 수 없어서다
471
631
  // (따옴표 세 종류를 다 흡수해야 하므로 백틱이 문자 클래스에 반드시 들어간다).
472
632
  /** 문자열 리터럴을 여는/닫는 따옴표 한 글자 — `"` · `'` · 백틱(```). */
473
- const QUOTE = '["\'\\u0060]';
633
+ const QUOTE = "[\"'\\u0060]";
474
634
  /** 따옴표가 아닌 한 글자(리터럴 안쪽). */
475
- const NOT_QUOTE = '[^"\'\\u0060]';
635
+ const NOT_QUOTE = "[^\"'\\u0060]";
476
636
 
477
637
  /** 헤더 이름 리터럴. Node(`req.headers["x-forwarded-for"]`)·web(`headers.get("x-forwarded-for")`) 양쪽을 흡수한다. */
478
638
  const XFF_NAME = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}`, "i");
@@ -487,10 +647,7 @@ const SPLIT_ON_COMMA = `\\.\\s*split\\s*\\(\\s*${QUOTE}${NOT_QUOTE}*,${NOT_QUOTE
487
647
  const FIRST_ENTRY = "(?:\\s*\\[\\s*0\\s*\\]|\\s*\\.\\s*at\\s*\\(\\s*0\\s*\\)|\\s*\\.\\s*shift\\s*\\(\\s*\\))";
488
648
 
489
649
  /** 헤더 리터럴 → split → 첫 엔트리가 **한 표현식으로** 이어진 형태. 사이의 `?.`·`!`·`.trim()` 등은 흡수한다. */
490
- const I1_DIRECT = new RegExp(
491
- `${QUOTE}x-forwarded-for${QUOTE}[\\s\\S]{0,120}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`,
492
- "i",
493
- );
650
+ const I1_DIRECT = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}[\\s\\S]{0,120}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`, "i");
494
651
 
495
652
  /** `const xff = <…x-forwarded-for…>` — 같은 파일 안 단순 변수 경유를 좇기 위한 바인딩 수집. */
496
653
  const I1_BINDING = new RegExp(
@@ -655,7 +812,10 @@ function importsClientAtRuntime(text) {
655
812
  if (!braces) return true; // 기본·네임스페이스·부수효과 import — 값이다
656
813
  const outside = clause.replace(/\{[^}]*\}/, "").replace(/[\s,]/g, "");
657
814
  if (outside.length > 0) return true; // `import z, {type Order} from …`
658
- 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);
659
819
  if (specifiers.length === 0) return true; // `import {} from …` — 부수효과
660
820
  if (specifiers.some((x) => !/^type\s/.test(x))) return true;
661
821
  }
@@ -688,7 +848,8 @@ function prescription(called) {
688
848
  const parts = [];
689
849
  if (direct.length > 0) parts.push(`${direct.join("·")} 는 \`(첫 인자, {clientIp})\``);
690
850
  if (viaAccess.length > 0) parts.push(`${viaAccess.join("·")} 는 \`(no, {phone, context: {clientIp}})\``);
691
- 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}})\``);
692
853
  return parts.join(" · ");
693
854
  }
694
855
 
@@ -720,7 +881,24 @@ function prescription(called) {
720
881
  * 아래 문자 뒤라면 값이 올 자리이므로 정규식이고, 그 밖(식별자·`)`·숫자 뒤)이면 나눗셈이다.
721
882
  */
722
883
  const BEFORE_REGEX_LITERAL = new Set([
723
- "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "~", "^",
884
+ "(",
885
+ ",",
886
+ "=",
887
+ ":",
888
+ "[",
889
+ "!",
890
+ "&",
891
+ "|",
892
+ "?",
893
+ "{",
894
+ "}",
895
+ ";",
896
+ "+",
897
+ "-",
898
+ "*",
899
+ "%",
900
+ "~",
901
+ "^",
724
902
  ]);
725
903
 
726
904
  /**
@@ -747,7 +925,20 @@ const BEFORE_REGEX_WORD = new Set([
747
925
  // 지금은 낱말이 아니라 **형태**로 가른다(아래 [isRegexStart]): `/` 다음이 공백·`{` 이거나 본문에
748
926
  // 리터럴 공백이 있으면 산문이다. 그래서 뺐던 낱말을 도로 넣어 잃었던 탐지를 회수했고,
749
927
  // 산문 6형상은 그대로 안 걸린다(심의 실측). 낱말 목록은 값-자리 판별용이지 산문 방어가 아니다.
750
- "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",
751
942
  ]);
752
943
 
753
944
  /**
@@ -954,7 +1145,10 @@ function maskCode(text, jsx = false) {
954
1145
  */
955
1146
  function callArguments(source, code, fn) {
956
1147
  const found = [];
957
- 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
+ );
958
1152
  // ⚠ 호출 **위치**는 원본에서 찾는다 — 브래킷 접근(`zalkera["getOrder"](…)`)은 이름 자체가 문자열
959
1153
  // 안이라 덮은 사본에서는 사라진다. 깊이만 덮은 사본에서 세면 된다(길이가 같아 오프셋이 맞는다).
960
1154
  for (const m of source.matchAll(opener)) {
@@ -1123,7 +1317,10 @@ function detectContentMode(srcDir) {
1123
1317
  const pkgPath = join(dir, "package.json");
1124
1318
  try {
1125
1319
  if (statSync(pkgPath).isFile()) {
1126
- 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);
1127
1324
  declared = pkg?.zalkera?.content ?? pkg?.oneque?.content;
1128
1325
  break;
1129
1326
  }
@@ -1137,9 +1334,9 @@ function detectContentMode(srcDir) {
1137
1334
  if (declared === "source") return "declared";
1138
1335
  if (declared === "sections-db") {
1139
1336
  warnings.push(
1140
- "[N0] package.json 의 `zalkera.content` 가 은퇴한 값 `\"sections-db\"` 입니다 — " +
1337
+ '[N0] package.json 의 `zalkera.content` 가 은퇴한 값 `"sections-db"` 입니다 — ' +
1141
1338
  "섹션이 백엔드 DB 에 살던 시절의 표기이고 그 거처는 퇴역했습니다(어휘 rev 7). " +
1142
- "사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `\"source\"` 로 바꾸세요 " +
1339
+ '사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `"source"` 로 바꾸세요 ' +
1143
1340
  "(llms.txt §9.1).",
1144
1341
  );
1145
1342
  return "none";
@@ -1172,7 +1369,10 @@ function contentPageFiles(repoRoot) {
1172
1369
  .filter((n) => n.endsWith(".json"))
1173
1370
  .sort()
1174
1371
  .map((n) => join(dir, n));
1175
- } catch {
1372
+ } catch (e) {
1373
+ // 콘텐츠 디렉터리는 **선택**이다 — 없는 것은 정상이고 "못 잰 것"이 아니다.
1374
+ // 그러나 있는데 못 읽는 것(권한·I/O)은 N 검사가 0줄 돈다는 뜻이라 적는다.
1375
+ if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(dir, e.code, "N 콘텐츠 페이지 수집");
1176
1376
  return [];
1177
1377
  }
1178
1378
  }
@@ -1200,11 +1400,28 @@ const DOC_PATH_TOKEN = /^[A-Za-z0-9_.\-/[\]]+\.(?:tsx?|jsx?|mjs|cjs|json|css|md)
1200
1400
  const DOC_PATH_SKIP_PREFIX = ["doc/", "node_modules/", ".zalkera/", "@", "/", "http"];
1201
1401
 
1202
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
+ */
1203
1420
  function docPathTokens(text, only) {
1204
1421
  const seen = new Set();
1205
1422
  for (const m of text.matchAll(/`([^`\n]+)`/g)) {
1206
1423
  const tok = m[1];
1207
- if (!tok.includes("/")) continue;
1424
+ if (!tok.includes("/")) continue; // 위 KDoc 의 실측 — 이름만 있는 토큰은 좌표가 아니다
1208
1425
  if (!DOC_PATH_TOKEN.test(tok)) continue;
1209
1426
  if (DOC_PATH_SKIP_PREFIX.some((p) => tok.startsWith(p))) continue;
1210
1427
  if (only && !only.test(tok)) continue;
@@ -1242,7 +1459,8 @@ function checkDocCoordinates() {
1242
1459
 
1243
1460
  const agents = join(repoRoot, "AGENTS.md");
1244
1461
  if (fileExists(agents)) {
1245
- for (const tok of docPathTokens(readFileSync(agents, "utf8"))) {
1462
+ const agentsText = readSafe(agents, "D1 문서 좌표 검사(AGENTS.md)");
1463
+ for (const tok of docPathTokens(agentsText ?? "")) {
1246
1464
  if (!fileExists(join(repoRoot, tok))) {
1247
1465
  sink.push(
1248
1466
  `[D1] AGENTS.md 가 없는 파일을 가리킵니다: \`${tok}\` — codegen 이 이 문서를 가장 먼저 ` +
@@ -1255,7 +1473,9 @@ function checkDocCoordinates() {
1255
1473
  // D2 — 본보기 레포 전용.
1256
1474
  let isExemplar = false;
1257
1475
  try {
1258
- 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";
1259
1479
  } catch {
1260
1480
  isExemplar = false;
1261
1481
  }
@@ -1263,7 +1483,8 @@ function checkDocCoordinates() {
1263
1483
 
1264
1484
  const llms = join(repoRoot, "node_modules", "@zalkera", "client", "llms.txt");
1265
1485
  if (!fileExists(llms)) return; // 미설치 — 없는 것을 센 척하지 않는다.
1266
- 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)\//)) {
1267
1488
  if (!fileExists(join(repoRoot, tok))) {
1268
1489
  sink.push(
1269
1490
  `[D2] llms.txt 가 본보기로 지목한 \`${tok}\` 이 이 레포에 없습니다 — 레시피가 실물을 ` +
@@ -1318,7 +1539,10 @@ function isSeoPageFile(file) {
1318
1539
 
1319
1540
  // SEO page 에서 금지되는 per-page SSR 유발 패턴들.
1320
1541
  const SSR_FORBIDDEN = [
1321
- {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
+ },
1322
1546
  {re: /export\s+const\s+revalidate\s*=\s*0\b/, why: `export const revalidate = 0 (ISR 무력화·매 요청 재생성)`},
1323
1547
  {re: /\bcookies\s*\(/, why: `page 레벨 cookies() 호출 (동적 렌더 opt-in — 세션값은 클라이언트 컴포넌트로)`},
1324
1548
  {re: /\bheaders\s*\(/, why: `page 레벨 headers() 호출 (동적 렌더 opt-in)`},
@@ -1326,12 +1550,155 @@ const SSR_FORBIDDEN = [
1326
1550
  {re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: `fetch(..., { next: { revalidate: 0 } }) (ISR 무력화)`},
1327
1551
  ];
1328
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
+
1329
1698
  function walk(dir) {
1330
- for (const name of readdirSync(dir)) {
1331
- if (name === "node_modules" || name === ".next") continue;
1332
- const full = join(dir, name);
1333
- if (statSync(full).isDirectory()) {
1334
- walk(full);
1699
+ walkTree(
1700
+ dir,
1701
+ (full, name) => {
1335
1702
  // ⚠ **관문 대가가 있다**(심의 실측): `.mjs` 를 훑으면서 `preview-runner --gate` 의 종료코드가
1336
1703
  // **0 → 1** 로 바뀐다(`[E3]` 예시 키 `oqsk_test_…` 를 오류층으로 잡는다). 실제 서빙 코퍼스는
1337
1704
  // rc=0 을 유지해 오늘의 폭발반경은 0 이지만, **발행이 남의 관문 계약을 바꾼다**는 사실은
@@ -1343,13 +1710,15 @@ function walk(dir) {
1343
1710
  // ⚠ `.mjs`·`.cjs` 도 훑는다. 종전엔 빠져 있어 **그 파일에서는 규칙이 아예 안 돌았다**
1344
1711
  // (심의 관찰 — 검사기가 조용한 것이지 안전한 것이 아니다). Next 설정·서버 유틸이 그 확장자로
1345
1712
  // 오는 레포가 있다.
1346
- } else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) {
1347
- check(full);
1348
- if (isLayoutFile(full)) layoutFiles.push(full);
1349
- } else if (name.endsWith(".css")) {
1350
- cssFiles.push(full);
1351
- }
1352
- }
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
+ );
1353
1722
  }
1354
1723
 
1355
1724
  // ── C1b: layout 폭발반경 게이트 ──────────────────────────────────
@@ -1372,7 +1741,10 @@ function isLayoutFile(file) {
1372
1741
  if (!/^(layout|template)\.(ts|tsx|js|jsx)$/.test(basename(file))) return false;
1373
1742
  const segments = relative(root, file).split(sep);
1374
1743
  if (segments[0] !== "app") return false;
1375
- 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));
1376
1748
  }
1377
1749
 
1378
1750
  /**
@@ -1409,7 +1781,32 @@ const DYNAMIC_API = [
1409
1781
  * (SiteHeader 주석이 정확히 그렇다). `//` 는 URL(`http://`)과 구분하려고 앞 문자가 `:` 이 아닐 때만.
1410
1782
  */
1411
1783
  function stripComments(src) {
1412
- 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 ");
1413
1810
  }
1414
1811
 
1415
1812
  /**
@@ -1548,7 +1945,9 @@ function checkLayoutBlastRadius() {
1548
1945
 
1549
1946
  // 면제는 **범인 파일**에 붙인다 — layout 에 붙이면 그 아래 전부가 한 번에 뚫린다.
1550
1947
  // 신 마커 zalkera- + 구 마커(oneq-/oneque-)를 양형 수용한다(리네임 이행기).
1551
- 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
+ );
1552
1951
  const path = chain.map((f) => relative(process.cwd(), f)).join(" → ");
1553
1952
  const detail =
1554
1953
  `${relative(process.cwd(), layout)} 이 ${why} 에 도달한다 → ${path}. ` +
@@ -1590,12 +1989,10 @@ function checkThemeWiring() {
1590
1989
  if (STYLE_MODE !== "declared") return; // 선언 없는 레포의 주입 부재는 결함이 아니라 정상이다.
1591
1990
 
1592
1991
  const globalsCss = join(root, "app", "globals.css");
1593
- let css = null;
1594
- try {
1595
- css = readFileSync(globalsCss, "utf8");
1596
- } catch {
1597
- return; // 파일 부재는 S3 가 이미 error 로 잡는다 — 같은 사실을 두 번 외치지 않는다.
1598
- }
1992
+ // 부재는 S3 가 이미 error 로 잡는다(같은 사실을 두 번 외치지 않는다). 그러나 **있는데 못 읽는 것**은
1993
+ // 다른 사실이고, 조용히 return 하면 S8 이 통째로 사라진 채 ✅ 가 난다(심의 실측).
1994
+ const css = readSafe(globalsCss, "S8 테마 토큰 검사(globals.css)");
1995
+ if (css === null) return;
1599
1996
 
1600
1997
  // S8-a — 토큰 정의.
1601
1998
  if (!/@theme\b/.test(css) || !/--color-primary\s*:/.test(css)) {
@@ -1617,7 +2014,8 @@ function checkThemeWiring() {
1617
2014
  });
1618
2015
  if (!rootLayout) return; // root layout 부재는 C1 계열의 몫.
1619
2016
 
1620
- const raw = readFileSync(rootLayout, "utf8");
2017
+ const raw = readSafe(rootLayout, "테마 주입 배선 검사(root layout)");
2018
+ if (raw === null) return;
1621
2019
  const src = stripComments(raw); // 주석은 거짓말을 한다 — 앵커를 코드에서만 찾는다.
1622
2020
  const injects = /parseThemeColors\s*\(/.test(src) && /<html[^>]*\sstyle=/.test(src);
1623
2021
  if (injects) return;
@@ -1644,12 +2042,9 @@ function checkSectionCoverage() {
1644
2042
  if (!Array.isArray(contract) || contract.length === 0) return;
1645
2043
 
1646
2044
  const rendererPath = join(root, "components/sections/SectionRenderer.tsx");
1647
- let src;
1648
- try {
1649
- src = readFileSync(rendererPath, "utf8");
1650
- } catch {
1651
- return; // 렌더러가 없는 구조(BYO) — 검사 대상 아님.
1652
- }
2045
+ // 렌더러가 없는 구조(BYO)는 검사 대상이 아니다. 못 읽는 것은 그것과 다른 사실이다.
2046
+ const src = readSafe(rendererPath, "섹션 렌더러 계약 검사");
2047
+ if (src === null) return;
1653
2048
  const cases = new Set([...src.matchAll(/case\s+"([A-Z_]+)"/g)].map((m) => m[1]));
1654
2049
  const missing = contract.map((c) => c.type).filter((t) => !cases.has(t));
1655
2050
  if (missing.length > 0) {
@@ -1746,10 +2141,10 @@ function checkContentContract() {
1746
2141
 
1747
2142
  // N1 — 매니페스트. 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다(계약 rev 4 `contentFile.manifest`).
1748
2143
  const manifestPath = join(repoRoot, "content", "index.ts");
1749
- let manifest = null;
1750
- try {
1751
- manifest = readFileSync(manifestPath, "utf8");
1752
- } catch {
2144
+ // 종전엔 EACCES 도 "없습니다"로 보고했다 — 있는데 못 읽는 것을 부재로 말하면 고칠 자리가 어긋난다.
2145
+ const manifest = readSafe(manifestPath, "N1 콘텐츠 매니페스트");
2146
+ if (manifest === null && existsSync(manifestPath)) return; // 못 읽음은 markUnmeasured 가 적었다
2147
+ if (manifest === null) {
1753
2148
  sink.push(
1754
2149
  `[N1] ${rel(manifestPath)} 가 없습니다 — 콘텐츠 매니페스트(정적 import)가 없으면 ` +
1755
2150
  `dev 에서 json 을 고쳐도 화면이 안 바뀌고(HMR 미발화), 빌드 산출물에 콘텐츠가 안 실립니다.`,
@@ -1761,9 +2156,7 @@ function checkContentContract() {
1761
2156
  // import 로 읽으면 없는 파일을 찾는 오탐이 난다(실제로 이 검사를 넣자마자 그렇게 죽었다).
1762
2157
  // 이 레포가 C1b·S8 에서 이미 밟은 함정과 같은 것이라 같은 처방을 쓴다.
1763
2158
  const declaredSlugs = manifest
1764
- ? new Set(
1765
- [...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([\w-]+)\.json["']/g)].map((m) => m[1]),
1766
- )
2159
+ ? new Set([...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([\w-]+)\.json["']/g)].map((m) => m[1]))
1767
2160
  : null;
1768
2161
  if (declaredSlugs) {
1769
2162
  for (const file of files) {
@@ -1777,7 +2170,9 @@ function checkContentContract() {
1777
2170
  }
1778
2171
  for (const slug of declaredSlugs) {
1779
2172
  if (!files.some((f) => basename(f, ".json") === slug)) {
1780
- sink.push(`[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`);
2173
+ sink.push(
2174
+ `[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`,
2175
+ );
1781
2176
  }
1782
2177
  }
1783
2178
  }
@@ -1786,15 +2181,19 @@ function checkContentContract() {
1786
2181
  warnIfContractMissing(contract);
1787
2182
 
1788
2183
  for (const file of files) {
2184
+ const pageText = readSafe(file, "N2 콘텐츠 페이지 판독");
2185
+ if (pageText === null) continue; // 못 읽음은 파싱 실패가 아니다
1789
2186
  let page;
1790
2187
  try {
1791
- page = JSON.parse(readFileSync(file, "utf8"));
2188
+ page = JSON.parse(pageText);
1792
2189
  } catch (e) {
1793
2190
  sink.push(`[N2] ${rel(file)}: JSON 파싱 실패 — ${e.message}`);
1794
2191
  continue;
1795
2192
  }
1796
2193
  if (page == null || typeof page !== "object" || Array.isArray(page)) {
1797
- sink.push(`[N2] ${rel(file)}: 최상위가 객체여야 합니다(현재 ${Array.isArray(page) ? "배열" : typeof page}).`);
2194
+ sink.push(
2195
+ `[N2] ${rel(file)}: 최상위가 객체여야 합니다(현재 ${Array.isArray(page) ? "배열" : typeof page}).`,
2196
+ );
1798
2197
  continue;
1799
2198
  }
1800
2199
 
@@ -1811,7 +2210,9 @@ function checkContentContract() {
1811
2210
  continue;
1812
2211
  }
1813
2212
  if (typeof section.type !== "string" || section.type.trim() === "") {
1814
- sink.push(`[N4] ${at}: type 은 비어 있지 않은 문자열이어야 합니다 — 렌더러가 이 섹션을 통째로 건너뜁니다.`);
2213
+ sink.push(
2214
+ `[N4] ${at}: type 은 비어 있지 않은 문자열이어야 합니다 — 렌더러가 이 섹션을 통째로 건너뜁니다.`,
2215
+ );
1815
2216
  continue;
1816
2217
  }
1817
2218
  if ("sortOrder" in section) {
@@ -1847,7 +2248,12 @@ function checkContentContract() {
1847
2248
  sink.push(`[N5] ${at}: "${path}" 는 public 루트 절대 경로 문자열이어야 합니다.`);
1848
2249
  continue;
1849
2250
  }
1850
- 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
+ ) {
1851
2257
  sink.push(
1852
2258
  `[N5] ${at}: "${path}" = ${JSON.stringify(value)} — 레포 public/ 루트 절대 경로만 그려집니다` +
1853
2259
  `(원격 URL·상대 경로·경로 탈출은 렌더에서 통째로 떨어집니다).`,
@@ -1857,7 +2263,9 @@ function checkContentContract() {
1857
2263
  try {
1858
2264
  statSync(join(repoRoot, "public", value));
1859
2265
  } catch {
1860
- sink.push(`[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`);
2266
+ sink.push(
2267
+ `[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`,
2268
+ );
1861
2269
  }
1862
2270
  }
1863
2271
  // N5 — 계약 필수 참조.
@@ -1894,7 +2302,15 @@ function checkContentContract() {
1894
2302
  }
1895
2303
 
1896
2304
  function check(file) {
1897
- 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
+ }
1898
2314
  const rel = relative(process.cwd(), file);
1899
2315
  // ⚠ **파일 머리만 본다(심의 차단 4).** 종전은 `/m` 플래그로 **원문 전체의 행머리**를 봤고,
1900
2316
  // 행머리에 `"use client"` 를 담은 **템플릿 리터럴 한 줄**이 있으면 SEO 페이지가 클라이언트로
@@ -1907,14 +2323,17 @@ function check(file) {
1907
2323
  // 서버 클라이언트 싱글턴 존재 확인 — create{Zalkera,Oneque}Client 호출(구 심볼 수용).
1908
2324
  if (/create(?:Zalkera|Oneque)Client\s*\(/.test(src)) {
1909
2325
  singletonFound = true;
1910
- if (isClient) servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
2326
+ if (isClient)
2327
+ servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
1911
2328
  }
1912
2329
 
1913
2330
  if (isClient) {
1914
2331
  // E1: 값 import(= import type 아님)로 @zalkera/client 를 들여옴(구 @oneque/client 도 잡는다).
1915
2332
  const valueImport = /^import\s+(?!type\s)[^;]*from\s+["']@(?:zalkera|oneque)\/client["']/m.test(src);
1916
2333
  if (valueImport) {
1917
- 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
+ );
1918
2337
  }
1919
2338
  // E2: 서버 싱글턴(lib/zalkera) import(구 lib/oneque 도 잡는다).
1920
2339
  if (/from\s+["'][^"']*lib\/(?:zalkera|oneque)["']/.test(src)) {
@@ -2065,8 +2484,9 @@ function checkStyleWiring() {
2065
2484
  }
2066
2485
  });
2067
2486
  if (rootLayout) {
2068
- const src = stripComments(readFileSync(rootLayout, "utf8"));
2069
- 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)) {
2070
2490
  s3.push(
2071
2491
  `[S3] ${relative(process.cwd(), rootLayout)} 이 globals.css 를 import 하지 않습니다 ` +
2072
2492
  `(\`import "./globals.css"\`). 배선이 없으면 Tailwind CSS·테마 토큰이 로드되지 않습니다.`,
@@ -2285,7 +2705,11 @@ function findMutationHandlers(code) {
2285
2705
  // ⒞ `export {POST}` · `export {handler as POST}` — 본문을 못 따라간다.
2286
2706
  for (const m of code.matchAll(/export\s*\{([^}]*)\}/g)) {
2287
2707
  for (const part of m[1].split(",")) {
2288
- 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();
2289
2713
  if (name && new RegExp(`^(${METHODS})$`).test(name)) push(name, null);
2290
2714
  }
2291
2715
  }
@@ -2374,22 +2798,17 @@ function judgeGuardPlacement(rawBody) {
2374
2798
  // 로 쓰면 플랫폼 존에서 형제 테넌트가 통과한다.
2375
2799
  function checkCrossOriginGuards() {
2376
2800
  const routes = [];
2377
- const collect = (dir) => {
2378
- let entries;
2379
- try {
2380
- entries = readdirSync(dir, {withFileTypes: true});
2381
- } catch {
2382
- return;
2383
- }
2384
- for (const e of entries) {
2385
- const full = join(dir, e.name);
2386
- if (e.isDirectory()) collect(full);
2387
- // ⚠ `route.js`·`.jsx`·`.mjs` 도 라우트다. 종전엔 `.ts`·`.tsx` 만 봐서 **가드 규칙(X1)
2388
- // 그 파일들에서 통째로 침묵**했다(I2 는 도는데 X1 만 안 도는 비대칭 · 심의 실측).
2389
- // 구식인 Pages Router 쪽(X1p)이 오히려 `.js` 를 보고 있었다.
2390
- else if (/^route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e.name)) routes.push(full);
2391
- }
2392
- };
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
+ );
2393
2812
  // ⚠ `root` 는 **레포 루트가 아니라 소스 루트**(`./src`)다(82행). 초판은 `src/app/api` 도
2394
2813
  // 함께 걸었는데 그건 `./src/src/app/api` 라 존재하지 않는 죽은 경로였다.
2395
2814
  //
@@ -2401,12 +2820,13 @@ function checkCrossOriginGuards() {
2401
2820
 
2402
2821
  const exempted = [];
2403
2822
  for (const file of routes) {
2404
- const code = readFileSync(file, "utf8");
2823
+ const code = readSafe(file, "X1 교차출처 가드 검사");
2824
+ if (code === null) continue;
2405
2825
  const rel = relative(root, file);
2406
2826
  // ⚠ **가드 축(X1·X1p)만 JSX 강등을 끈다.** JSX 파일에서 낱말 뒤 정규식을 나눗셈으로 두면
2407
- // 그 안의 백틱이 살아나 파일을 삼켜 **가드가 안 보인다**(미탐). 산문 삼킴은 I2·X3 축에만
2408
- // 해로우므로, 두 축을 갈라 각각에 맞는 사본을 준다(심의 처방 · 미탐 4·오탐 2 동시 해소).
2409
- const handlers = findMutationHandlers(stripLiterals(code, false));
2827
+ // 그 안의 백틱이 살아나 파일을 삼켜 **가드가 안 보인다**(미탐). 산문 삼킴은 I2·X3 축에만
2828
+ // 해로우므로, 두 축을 갈라 각각에 맞는 사본을 준다(심의 처방 · 미탐 4·오탐 2 동시 해소).
2829
+ const handlers = findMutationHandlers(stripLiterals(code, false));
2410
2830
  if (!handlers.length) continue;
2411
2831
 
2412
2832
  // 면제 마커는 **파일 상단**에만 둔다 — 아무 데나 허용하면 주석·문자열 안의 한 줄로
@@ -2441,7 +2861,8 @@ function checkCrossOriginGuards() {
2441
2861
 
2442
2862
  // X2 — 읽기 GET 면제는 "CORS 헤더가 없다"에 의존한다. 그 전제가 깨지면 면제도 깨진다.
2443
2863
  for (const file of routes) {
2444
- const code = readFileSync(file, "utf8");
2864
+ const code = readSafe(file, "X2 CORS 헤더 검사");
2865
+ if (code === null) continue;
2445
2866
  if (/Access-Control-Allow-Origin/i.test(code)) {
2446
2867
  crossOriginSink().push(
2447
2868
  `[X2] ${relative(root, file)} 가 CORS 헤더를 답니다 — 읽기 GET 을 가드에서 빼는 근거가` +
@@ -2460,25 +2881,19 @@ function checkCrossOriginGuards() {
2460
2881
  // **fail-open** 했다(심의 실측: 소각 삭제 + 리네임 = 통과). `./src/src/app/api` 이후
2461
2882
  // 좌표가 죽어 검사가 사라진 **세 번째 사례**라 정의를 찾아가는 쪽으로 바꾼다.
2462
2883
  const sources = [];
2463
- const collectSources = (dir) => {
2464
- let entries;
2465
- try {
2466
- entries = readdirSync(dir, {withFileTypes: true});
2467
- } catch {
2468
- return;
2469
- }
2470
- for (const e of entries) {
2471
- const full = join(dir, e.name);
2472
- if (e.isDirectory()) collectSources(full);
2473
- else if (/\.tsx?$/.test(e.name)) sources.push(full);
2474
- }
2475
- };
2476
- collectSources(root);
2884
+ walkTree(
2885
+ root,
2886
+ (full, name) => {
2887
+ if (/\.tsx?$/.test(name)) sources.push(full);
2888
+ },
2889
+ "X3 소스 수집(OAuth state 소각)",
2890
+ );
2477
2891
 
2478
2892
  let consumeDef = null;
2479
2893
  let usesStateCookie = false;
2480
2894
  for (const file of sources) {
2481
- const raw = readFileSync(file, "utf8");
2895
+ const raw = readSafe(file, "파일 읽기");
2896
+ if (raw === null) continue;
2482
2897
  const src = stripLiterals(raw, holdsJsx(file, raw)); // X3 — 산문 삼킴을 막아야 하는 축
2483
2898
  if (/\bconsumeOAuthState\s*\(/.test(src)) usesStateCookie = true;
2484
2899
  // 정의 형태를 묻지 않는다 — function 선언·화살표 상수 둘 다.
@@ -2567,20 +2982,15 @@ function pagesRoot() {
2567
2982
  return null;
2568
2983
  }
2569
2984
 
2570
- /** 디렉터리 아래 소스 파일 전량(재귀). `node_modules`·`.next` 건너뛴다. */
2985
+ /** 디렉터리 아래 소스 파일 전량(재귀). 걷는 규율은 [walkTree] 하나에 모여 있다. */
2571
2986
  function collectSourceFiles(dir, into = []) {
2572
- let entries;
2573
- try {
2574
- entries = readdirSync(dir, {withFileTypes: true});
2575
- } catch {
2576
- return into;
2577
- }
2578
- for (const e of entries) {
2579
- if (e.name === "node_modules" || e.name === ".next") continue;
2580
- const full = join(dir, e.name);
2581
- if (e.isDirectory()) collectSourceFiles(full, into);
2582
- else if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name)) into.push(full);
2583
- }
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
+ );
2584
2994
  return into;
2585
2995
  }
2586
2996
 
@@ -2602,7 +3012,8 @@ function checkPagesRouter() {
2602
3012
 
2603
3013
  for (const file of collectSourceFiles(pagesDir)) {
2604
3014
  const rel = relative(process.cwd(), file);
2605
- const raw = readFileSync(file, "utf8");
3015
+ const raw = readSafe(file, "파일 읽기");
3016
+ if (raw === null) continue;
2606
3017
  const text = stripComments(raw);
2607
3018
  const stem = basename(file).replace(/\.[^.]+$/, "");
2608
3019
 
@@ -2680,16 +3091,18 @@ function checkEnvFiles() {
2680
3091
  let names;
2681
3092
  try {
2682
3093
  names = readdirSync(repoRoot).filter((n) => n === ".env" || n.startsWith(".env."));
2683
- } catch {
3094
+ } catch (e) {
3095
+ // 레포 루트를 못 읽으면 E3 가 **한 줄도 안 돈다.** 조용히 return 하면 ✅ 가 난다.
3096
+ if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(repoRoot, e.code, "E3 환경파일 스캔");
2684
3097
  return;
2685
3098
  }
2686
3099
  for (const name of names.sort()) {
2687
- let lines;
2688
- try {
2689
- lines = readFileSync(join(repoRoot, name), "utf8").split("\n");
2690
- } catch {
2691
- continue;
2692
- }
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");
2693
3106
  for (const line of lines) {
2694
3107
  if (/^\s*#/.test(line)) continue; // 주석 — `.env.example` 이 금지 규약을 문장으로 적어 뒀다.
2695
3108
  const m = line.match(PUBLIC_SECRET_ENV);
@@ -2744,12 +3157,9 @@ function checkServingOutputContract() {
2744
3157
  // 설정 파일이 여럿이면 어느 것이 유효한지 Next 의 해석 순서에 달렸다 — 못 가르는 자리라 스킵한다.
2745
3158
  if (found.length !== 1) return;
2746
3159
 
2747
- let text;
2748
- try {
2749
- text = stripComments(readFileSync(found[0], "utf8"));
2750
- } catch {
2751
- return;
2752
- }
3160
+ const configText = readSafe(found[0], "O1 서빙 산출물 계약 검사(next.config)");
3161
+ if (configText === null) return;
3162
+ const text = stripComments(configText);
2753
3163
  if (!isStaticallyReadableConfig(text)) return; // 동적 조립 — 잠자코 스킵.
2754
3164
 
2755
3165
  const name = basename(found[0]);
@@ -2773,8 +3183,16 @@ function checkServingOutputContract() {
2773
3183
  servingOutputSink().push(`[O1] ${name}: output 설정이 없습니다 — ${advice}`);
2774
3184
  }
2775
3185
 
3186
+ // ⚠ `statSync` 만으로는 **파일을 소스 루트로 준 경우**를 못 가른다. `walkTree` 는 시작 좌표의
3187
+ // ENOTDIR 를 "선택적 하위 디렉터리 부재"로 관용하므로, 그대로 두면 훑을 것이 0개인 채 `✅ rc=0` 으로
3188
+ // 끝난다(심의 실측 — 구판은 크래시로나마 관문을 닫았다). 선택은 하위 좌표의 성질이지 시작 좌표의
3189
+ // 성질이 아니다. 여기서 틀리면 **검사를 한 줄도 안 돌고 통과**가 난다.
2776
3190
  try {
2777
- statSync(root);
3191
+ if (!statSync(root).isDirectory()) {
3192
+ console.error(`디렉터리가 아닙니다(소스 디렉터리를 주십시오 · 보통 ./src): ${root}`);
3193
+ console.error(`검사를 시작하지 못했습니다 — 통과가 아닙니다.`);
3194
+ process.exit(2);
3195
+ }
2778
3196
  } catch {
2779
3197
  console.error(`디렉터리를 찾을 수 없습니다: ${root}`);
2780
3198
  process.exit(2);
@@ -2827,7 +3245,8 @@ function checkManualCarrier() {
2827
3245
 
2828
3246
  checkManualCarrier(); // W-LLMS — zip 이 나르는 명세가 설치본과 같은가(fail-soft).
2829
3247
 
2830
- if (!singletonFound) warnings.push(`[W1] createZalkeraClient 싱글턴을 찾지 못했습니다 — 서버 사이드 호출 패턴이 있는지 확인하세요.`);
3248
+ if (!singletonFound)
3249
+ warnings.push(`[W1] createZalkeraClient 싱글턴을 찾지 못했습니다 — 서버 사이드 호출 패턴이 있는지 확인하세요.`);
2831
3250
 
2832
3251
  console.log(
2833
3252
  `스타일 규약 모드: ${STYLE_MODE}` +
@@ -2848,8 +3267,50 @@ console.log(
2848
3267
  for (const w of warnings) console.warn("⚠️ " + w);
2849
3268
  for (const e of errors) console.error("❌ " + e);
2850
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
+
2851
3301
  if (errors.length > 0) {
2852
3302
  console.error(`\n${errors.length}개 오류. 스토어프론트 규약 위반을 고치세요 (llms.txt §5 참고).`);
2853
3303
  process.exit(1);
2854
3304
  }
2855
- 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
+ );