@zalkera/client 0.21.2 → 0.21.4
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.
- package/bin/check-aeo-surfaces.mjs +75 -18
- package/bin/validate-storefront.mjs +686 -157
- package/dist/index.cjs +12 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +12 -16
- package/dist/index.js.map +1 -1
- package/package.json +65 -60
|
@@ -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,85 @@ 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
|
+
/** 한 파일을 읽어들이는 상한. 근거는 `readSafe` 주석. */
|
|
174
|
+
const MAX_READ_BYTES = 8 * 1024 * 1024;
|
|
175
|
+
|
|
176
|
+
/** 못 잰 것을 적는다. 사유(errno)와 무엇을 못 했는지를 함께 남긴다 — "안 잰 것을 잰 척" 하지 않기 위해. */
|
|
177
|
+
function markUnmeasured(path, code, what) {
|
|
178
|
+
unmeasured.push({path: relative(process.cwd(), path), code: code ?? "UNKNOWN", what});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 못 읽으면 **적고 `null` 을 준다.** 조용한 `catch {}` 와 다르다 — 부재의 사유가 남는다.
|
|
183
|
+
*
|
|
184
|
+
* 이 검사기의 "못 읽음" 처분이 종전엔 네 가지로 갈려 있었다(심의 실측): 크래시(walk·AGENTS.md) ·
|
|
185
|
+
* 조용한 소멸(`.env`·`globals.css`) · 오분류 고지(N2 가 IO 오류를 "JSON 파싱 실패"로) · 통과.
|
|
186
|
+
* 한 자리로 모은다.
|
|
187
|
+
*/
|
|
188
|
+
function readSafe(file, what) {
|
|
189
|
+
try {
|
|
190
|
+
// ⚠ 크기 상한. 이 검사기는 **신뢰 밖 소스**에도 도는데(서빙 샌드박스), 상한이 없으면 import
|
|
191
|
+
// 한 줄로 임의 크기 파일을 읽혀 빌드 박스 메모리를 밀어낼 수 있다 — 실측으로 레포 밖 400MB
|
|
192
|
+
// 파일 하나에 RSS 47MB → 812MB 였다. 넘으면 **못 잰 것으로 적는다**(조용히 건너뛰면 통과로
|
|
193
|
+
// 읽힌다). 소스 한 파일이 이 크기를 넘는 것은 그 자체로 사람이 볼 일이다.
|
|
194
|
+
const {size} = statSync(file);
|
|
195
|
+
if (size > MAX_READ_BYTES) {
|
|
196
|
+
markUnmeasured(
|
|
197
|
+
file,
|
|
198
|
+
"EFBIG",
|
|
199
|
+
`${what} — ${Math.round(size / 1048576)}MB(상한 ${MAX_READ_BYTES / 1048576}MB)`,
|
|
200
|
+
);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return readFileSync(file, "utf8");
|
|
204
|
+
} catch (e) {
|
|
205
|
+
if (e.code === "ENOENT") return null; // 없는 파일은 "못 잰 것"이 아니다 — 규칙이 판단할 사실이다
|
|
206
|
+
markUnmeasured(file, e.code, what);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* **안전망** — 예기치 못한 예외가 raw 스택트레이스로 나가지 않게 한다.
|
|
213
|
+
*
|
|
214
|
+
* `build.sh` 가 스스로 적어 둔 「종료코드의 수신자는 비개발자다」가 근거다. 종전엔 파일시스템 예외
|
|
215
|
+
* 하나가 Node 스택 8줄 + `errno: -40` 으로 튀어나왔고, `verify-zip` 은 그것을 **납품 반려 사유로
|
|
216
|
+
* 그대로 인쇄**했다(실측). 납품사는 무엇을 고치라는 것인지 알 수 없었고, 실제로는 규약 검사가
|
|
217
|
+
* **0줄 돌았다.**
|
|
218
|
+
*
|
|
219
|
+
* rc=2(도구 오류)를 쓴다 — rc=1 은 "규약 위반"의 뜻이라 도구가 죽은 것과 섞이면 안 된다.
|
|
220
|
+
* 스택은 `ZALKERA_VALIDATE_DEBUG=1` 일 때만 보인다.
|
|
221
|
+
*/
|
|
222
|
+
process.on("uncaughtException", (err) => {
|
|
223
|
+
console.error(`\n❌ 검사기가 예기치 못한 오류로 멈췄습니다 — 규약 검사가 끝나지 않았습니다(통과가 아닙니다).`);
|
|
224
|
+
console.error(` ${err?.code ? `[${err.code}] ` : ""}${err?.message ?? err}`);
|
|
225
|
+
if (err?.path) console.error(` 대상: ${err.path}`);
|
|
226
|
+
console.error(
|
|
227
|
+
` 개발자에게 이 줄을 전달하십시오. 자세한 스택은 ZALKERA_VALIDATE_DEBUG=1 로 다시 실행하면 나옵니다.`,
|
|
228
|
+
);
|
|
229
|
+
if (process.env.ZALKERA_VALIDATE_DEBUG === "1") console.error(err);
|
|
230
|
+
process.exit(2);
|
|
231
|
+
});
|
|
232
|
+
|
|
154
233
|
const root = resolveSourceRoot();
|
|
155
234
|
|
|
156
235
|
/**
|
|
@@ -175,9 +254,18 @@ const repoRootDir = resolveRepoRoot(root);
|
|
|
175
254
|
* 주의: 우리 `muted` 는 **글자색**이라 `bg-muted`(shadcn 은 배경)와 의미가 다르다.
|
|
176
255
|
*/
|
|
177
256
|
const FOREIGN_TOKEN_CLASSES = [
|
|
178
|
-
"bg-card",
|
|
179
|
-
"
|
|
180
|
-
"
|
|
257
|
+
"bg-card",
|
|
258
|
+
"bg-popover",
|
|
259
|
+
"bg-muted",
|
|
260
|
+
"bg-accent",
|
|
261
|
+
"bg-destructive",
|
|
262
|
+
"text-card-foreground",
|
|
263
|
+
"text-popover-foreground",
|
|
264
|
+
"text-muted-foreground",
|
|
265
|
+
"text-accent-foreground",
|
|
266
|
+
"text-destructive",
|
|
267
|
+
"ring-ring",
|
|
268
|
+
"border-input",
|
|
181
269
|
];
|
|
182
270
|
|
|
183
271
|
/*
|
|
@@ -199,8 +287,21 @@ const FOREIGN_TOKEN_CLASSES = [
|
|
|
199
287
|
* 검정이고 브랜드 색 표면이 아니다. 넣으면 정상 코드가 대량으로 빨개진다. 즉 이 축은 **안 잰다**.
|
|
200
288
|
*/
|
|
201
289
|
const COLOR_UTILITY_ROOTS = new Set([
|
|
202
|
-
"bg",
|
|
203
|
-
"
|
|
290
|
+
"bg",
|
|
291
|
+
"text",
|
|
292
|
+
"border",
|
|
293
|
+
"ring",
|
|
294
|
+
"outline",
|
|
295
|
+
"decoration",
|
|
296
|
+
"divide",
|
|
297
|
+
"placeholder",
|
|
298
|
+
"caret",
|
|
299
|
+
"accent",
|
|
300
|
+
"fill",
|
|
301
|
+
"stroke",
|
|
302
|
+
"from",
|
|
303
|
+
"via",
|
|
304
|
+
"to",
|
|
204
305
|
]);
|
|
205
306
|
/** 색 함수. `color-mix(` 까지 — Tailwind 임의값 안에서 전부 유효하다. */
|
|
206
307
|
const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\s*\(/;
|
|
@@ -213,13 +314,65 @@ const HEX_COLOR = /#[0-9a-fA-F]{3,8}(?![0-9a-zA-Z])/;
|
|
|
213
314
|
* 아니라 위임이라 일부러 뺐다(Tailwind 에 `bg-transparent`·`text-current` 가 이미 있다).
|
|
214
315
|
*/
|
|
215
316
|
const NAMED_COLORS = new Set([
|
|
216
|
-
"red",
|
|
217
|
-
"
|
|
218
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
317
|
+
"red",
|
|
318
|
+
"blue",
|
|
319
|
+
"green",
|
|
320
|
+
"black",
|
|
321
|
+
"white",
|
|
322
|
+
"gray",
|
|
323
|
+
"grey",
|
|
324
|
+
"orange",
|
|
325
|
+
"purple",
|
|
326
|
+
"pink",
|
|
327
|
+
"yellow",
|
|
328
|
+
"brown",
|
|
329
|
+
"cyan",
|
|
330
|
+
"magenta",
|
|
331
|
+
"navy",
|
|
332
|
+
"teal",
|
|
333
|
+
"olive",
|
|
334
|
+
"maroon",
|
|
335
|
+
"lime",
|
|
336
|
+
"aqua",
|
|
337
|
+
"silver",
|
|
338
|
+
"gold",
|
|
339
|
+
"indigo",
|
|
340
|
+
"violet",
|
|
341
|
+
"crimson",
|
|
342
|
+
"coral",
|
|
343
|
+
"salmon",
|
|
344
|
+
"tomato",
|
|
345
|
+
"khaki",
|
|
346
|
+
"beige",
|
|
347
|
+
"ivory",
|
|
348
|
+
"azure",
|
|
349
|
+
"plum",
|
|
350
|
+
"orchid",
|
|
351
|
+
"turquoise",
|
|
352
|
+
"lavender",
|
|
353
|
+
"tan",
|
|
354
|
+
"wheat",
|
|
355
|
+
"snow",
|
|
356
|
+
"skyblue",
|
|
357
|
+
"hotpink",
|
|
358
|
+
"darkblue",
|
|
359
|
+
"darkred",
|
|
360
|
+
"darkgreen",
|
|
361
|
+
"lightblue",
|
|
362
|
+
"lightgray",
|
|
363
|
+
"lightgrey",
|
|
364
|
+
"midnightblue",
|
|
365
|
+
"steelblue",
|
|
366
|
+
"slategray",
|
|
367
|
+
"slategrey",
|
|
368
|
+
"firebrick",
|
|
369
|
+
"forestgreen",
|
|
370
|
+
"seagreen",
|
|
371
|
+
"royalblue",
|
|
372
|
+
"dodgerblue",
|
|
373
|
+
"chocolate",
|
|
374
|
+
"sienna",
|
|
375
|
+
"peru",
|
|
223
376
|
]);
|
|
224
377
|
|
|
225
378
|
/**
|
|
@@ -316,7 +469,11 @@ function detectStyleMode(srcDir) {
|
|
|
316
469
|
const pkgPath = join(dir, "package.json");
|
|
317
470
|
try {
|
|
318
471
|
if (statSync(pkgPath).isFile()) {
|
|
319
|
-
|
|
472
|
+
// 못 읽으면 모드가 조용히 강등되어 **S 규칙군이 통째로 스킵**되고 ✅ rc 0 이 난다
|
|
473
|
+
// (심의 실측). 선언을 안 한 것과 못 읽은 것은 다른 사실이다.
|
|
474
|
+
const declText = readSafe(pkgPath, "스타일 규약 모드 판정(package.json)");
|
|
475
|
+
if (declText === null) return "none";
|
|
476
|
+
const pkg = JSON.parse(declText);
|
|
320
477
|
const styling = pkg?.zalkera?.styling ?? pkg?.oneque?.styling;
|
|
321
478
|
if (styling === "tailwind-tokens") return "declared";
|
|
322
479
|
if (styling !== undefined) return "none"; // 검사 규칙 없는 다른 선언값 = 잘커라 스타일 규약 없음
|
|
@@ -396,6 +553,25 @@ function servingSink() {
|
|
|
396
553
|
* 반면 `E`(시크릿이 브라우저 번들에 실림)·`C`(SEO 라우트가 동적 SSR 강제)는 **사실을 잰다** — 오탐 여지가
|
|
397
554
|
* 없고 상용 실측도 0건이라, 관문으로 켜도 아무도 안 막힌다. 그래서 그 둘만 `servingSink` 를 쓴다.
|
|
398
555
|
*/
|
|
556
|
+
/**
|
|
557
|
+
* **X 축(X1·X1p·X3) 전용 목적지 — 승격은 영구 금지다**(memo140 §6.5).
|
|
558
|
+
*
|
|
559
|
+
* [clientIpSink] 와 지금은 같은 배열로 가지만 **함수가 다르다.** 이유는 [clientIpDeclarationSink] 와 같다:
|
|
560
|
+
* 남이 I1 을 `servingSink()` 로 승격시키는 날, 싱크를 공유하면 X 축이 **딸려 올라간다.**
|
|
561
|
+
*
|
|
562
|
+
* **왜 영구 금지인가.** "다른 수단으로 막고 있으니까"가 아니다 — 그렇게 적었다가 보안 심의 실측에
|
|
563
|
+
* 반증됐다(상용 사이트가 교차 오리진 요청을 200 으로 접수). 근거는 **이 검사가 안전을 재지 못한다**
|
|
564
|
+
* 는 것이다: X1 은 *"교차 오리진을 막았는가"* 가 아니라 *"우리 심볼 `assertSameOrigin` 을 썼는가"* 를
|
|
565
|
+
* 잰다. 동명 로컬 함수를 선언하면 통과하므로 **통과가 안전을 뜻하지 않는다.** 재지 못하는 것으로
|
|
566
|
+
* 남의 빌드를 막으면, 막는 대가는 치르고 얻는 안전은 없다.
|
|
567
|
+
*
|
|
568
|
+
* 그 결정의 **나머지 절반**은 "막지 않는 대신 반드시 보여야 한다"이다 — 이 경고가 업로드·검수 결과에
|
|
569
|
+
* 실제로 뜨지 않으면 그냥 방치다. ⚠ 오늘 이 경고는 `/api/system` 에만 보인다. 고칠 수 있는 당사자
|
|
570
|
+
* (파트너)가 못 보는 상태이고, 그 노출은 아직 안 만들었다.
|
|
571
|
+
*
|
|
572
|
+
* 그리고 X3 는 **경고다.** memo118 §2 가 "X3 가 strict 회귀를 error 로 막는다"고 적어 뒀던 것은
|
|
573
|
+
* 사실이 아니었다(2026-08-14 정정 · 그 문장이 근거로 인용되던 자리라 문서도 같이 고쳤다).
|
|
574
|
+
*/
|
|
399
575
|
function crossOriginSink() {
|
|
400
576
|
return warnings;
|
|
401
577
|
}
|
|
@@ -470,9 +646,9 @@ function servingOutputSink() {
|
|
|
470
646
|
// ⚠ 아래 조각들은 **일반 문자열**로 쓴다. 정규식 리터럴·템플릿 리터럴 안에 백틱을 담을 수 없어서다
|
|
471
647
|
// (따옴표 세 종류를 다 흡수해야 하므로 백틱이 문자 클래스에 반드시 들어간다).
|
|
472
648
|
/** 문자열 리터럴을 여는/닫는 따옴표 한 글자 — `"` · `'` · 백틱(```). */
|
|
473
|
-
const QUOTE =
|
|
649
|
+
const QUOTE = "[\"'\\u0060]";
|
|
474
650
|
/** 따옴표가 아닌 한 글자(리터럴 안쪽). */
|
|
475
|
-
const NOT_QUOTE =
|
|
651
|
+
const NOT_QUOTE = "[^\"'\\u0060]";
|
|
476
652
|
|
|
477
653
|
/** 헤더 이름 리터럴. Node(`req.headers["x-forwarded-for"]`)·web(`headers.get("x-forwarded-for")`) 양쪽을 흡수한다. */
|
|
478
654
|
const XFF_NAME = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}`, "i");
|
|
@@ -487,10 +663,7 @@ const SPLIT_ON_COMMA = `\\.\\s*split\\s*\\(\\s*${QUOTE}${NOT_QUOTE}*,${NOT_QUOTE
|
|
|
487
663
|
const FIRST_ENTRY = "(?:\\s*\\[\\s*0\\s*\\]|\\s*\\.\\s*at\\s*\\(\\s*0\\s*\\)|\\s*\\.\\s*shift\\s*\\(\\s*\\))";
|
|
488
664
|
|
|
489
665
|
/** 헤더 리터럴 → 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
|
-
);
|
|
666
|
+
const I1_DIRECT = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}[\\s\\S]{0,120}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`, "i");
|
|
494
667
|
|
|
495
668
|
/** `const xff = <…x-forwarded-for…>` — 같은 파일 안 단순 변수 경유를 좇기 위한 바인딩 수집. */
|
|
496
669
|
const I1_BINDING = new RegExp(
|
|
@@ -655,7 +828,10 @@ function importsClientAtRuntime(text) {
|
|
|
655
828
|
if (!braces) return true; // 기본·네임스페이스·부수효과 import — 값이다
|
|
656
829
|
const outside = clause.replace(/\{[^}]*\}/, "").replace(/[\s,]/g, "");
|
|
657
830
|
if (outside.length > 0) return true; // `import z, {type Order} from …`
|
|
658
|
-
const specifiers = braces[1]
|
|
831
|
+
const specifiers = braces[1]
|
|
832
|
+
.split(",")
|
|
833
|
+
.map((x) => x.trim())
|
|
834
|
+
.filter(Boolean);
|
|
659
835
|
if (specifiers.length === 0) return true; // `import {} from …` — 부수효과
|
|
660
836
|
if (specifiers.some((x) => !/^type\s/.test(x))) return true;
|
|
661
837
|
}
|
|
@@ -688,7 +864,8 @@ function prescription(called) {
|
|
|
688
864
|
const parts = [];
|
|
689
865
|
if (direct.length > 0) parts.push(`${direct.join("·")} 는 \`(첫 인자, {clientIp})\``);
|
|
690
866
|
if (viaAccess.length > 0) parts.push(`${viaAccess.join("·")} 는 \`(no, {phone, context: {clientIp}})\``);
|
|
691
|
-
if (third.length > 0)
|
|
867
|
+
if (third.length > 0)
|
|
868
|
+
parts.push(`${third.join("·")} 는 **세 번째** 인자라 \`(no, params, {phone, context: {clientIp}})\``);
|
|
692
869
|
return parts.join(" · ");
|
|
693
870
|
}
|
|
694
871
|
|
|
@@ -720,7 +897,24 @@ function prescription(called) {
|
|
|
720
897
|
* 아래 문자 뒤라면 값이 올 자리이므로 정규식이고, 그 밖(식별자·`)`·숫자 뒤)이면 나눗셈이다.
|
|
721
898
|
*/
|
|
722
899
|
const BEFORE_REGEX_LITERAL = new Set([
|
|
723
|
-
"(",
|
|
900
|
+
"(",
|
|
901
|
+
",",
|
|
902
|
+
"=",
|
|
903
|
+
":",
|
|
904
|
+
"[",
|
|
905
|
+
"!",
|
|
906
|
+
"&",
|
|
907
|
+
"|",
|
|
908
|
+
"?",
|
|
909
|
+
"{",
|
|
910
|
+
"}",
|
|
911
|
+
";",
|
|
912
|
+
"+",
|
|
913
|
+
"-",
|
|
914
|
+
"*",
|
|
915
|
+
"%",
|
|
916
|
+
"~",
|
|
917
|
+
"^",
|
|
724
918
|
]);
|
|
725
919
|
|
|
726
920
|
/**
|
|
@@ -747,7 +941,20 @@ const BEFORE_REGEX_WORD = new Set([
|
|
|
747
941
|
// 지금은 낱말이 아니라 **형태**로 가른다(아래 [isRegexStart]): `/` 다음이 공백·`{` 이거나 본문에
|
|
748
942
|
// 리터럴 공백이 있으면 산문이다. 그래서 뺐던 낱말을 도로 넣어 잃었던 탐지를 회수했고,
|
|
749
943
|
// 산문 6형상은 그대로 안 걸린다(심의 실측). 낱말 목록은 값-자리 판별용이지 산문 방어가 아니다.
|
|
750
|
-
"return",
|
|
944
|
+
"return",
|
|
945
|
+
"throw",
|
|
946
|
+
"typeof",
|
|
947
|
+
"void",
|
|
948
|
+
"await",
|
|
949
|
+
"yield",
|
|
950
|
+
"instanceof",
|
|
951
|
+
"case",
|
|
952
|
+
"else",
|
|
953
|
+
"in",
|
|
954
|
+
"of",
|
|
955
|
+
"new",
|
|
956
|
+
"delete",
|
|
957
|
+
"do",
|
|
751
958
|
]);
|
|
752
959
|
|
|
753
960
|
/**
|
|
@@ -954,7 +1161,10 @@ function maskCode(text, jsx = false) {
|
|
|
954
1161
|
*/
|
|
955
1162
|
function callArguments(source, code, fn) {
|
|
956
1163
|
const found = [];
|
|
957
|
-
const opener = new RegExp(
|
|
1164
|
+
const opener = new RegExp(
|
|
1165
|
+
`(?:\\.|\\?\\.)\\s*${fn}\\s*(?:\\?\\.)?\\s*\\(|\\[\\s*["'\`]${fn}["'\`]\\s*\\]\\s*\\(`,
|
|
1166
|
+
"g",
|
|
1167
|
+
);
|
|
958
1168
|
// ⚠ 호출 **위치**는 원본에서 찾는다 — 브래킷 접근(`zalkera["getOrder"](…)`)은 이름 자체가 문자열
|
|
959
1169
|
// 안이라 덮은 사본에서는 사라진다. 깊이만 덮은 사본에서 세면 된다(길이가 같아 오프셋이 맞는다).
|
|
960
1170
|
for (const m of source.matchAll(opener)) {
|
|
@@ -1123,7 +1333,10 @@ function detectContentMode(srcDir) {
|
|
|
1123
1333
|
const pkgPath = join(dir, "package.json");
|
|
1124
1334
|
try {
|
|
1125
1335
|
if (statSync(pkgPath).isFile()) {
|
|
1126
|
-
|
|
1336
|
+
// 위와 같은 자리 — 못 읽으면 N 규칙군이 통째로 스킵된다.
|
|
1337
|
+
const declText = readSafe(pkgPath, "콘텐츠 규약 모드 판정(package.json)");
|
|
1338
|
+
if (declText === null) break;
|
|
1339
|
+
const pkg = JSON.parse(declText);
|
|
1127
1340
|
declared = pkg?.zalkera?.content ?? pkg?.oneque?.content;
|
|
1128
1341
|
break;
|
|
1129
1342
|
}
|
|
@@ -1137,9 +1350,9 @@ function detectContentMode(srcDir) {
|
|
|
1137
1350
|
if (declared === "source") return "declared";
|
|
1138
1351
|
if (declared === "sections-db") {
|
|
1139
1352
|
warnings.push(
|
|
1140
|
-
|
|
1353
|
+
'[N0] package.json 의 `zalkera.content` 가 은퇴한 값 `"sections-db"` 입니다 — ' +
|
|
1141
1354
|
"섹션이 백엔드 DB 에 살던 시절의 표기이고 그 거처는 퇴역했습니다(어휘 rev 7). " +
|
|
1142
|
-
|
|
1355
|
+
'사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `"source"` 로 바꾸세요 ' +
|
|
1143
1356
|
"(llms.txt §9.1).",
|
|
1144
1357
|
);
|
|
1145
1358
|
return "none";
|
|
@@ -1172,7 +1385,10 @@ function contentPageFiles(repoRoot) {
|
|
|
1172
1385
|
.filter((n) => n.endsWith(".json"))
|
|
1173
1386
|
.sort()
|
|
1174
1387
|
.map((n) => join(dir, n));
|
|
1175
|
-
} catch {
|
|
1388
|
+
} catch (e) {
|
|
1389
|
+
// 콘텐츠 디렉터리는 **선택**이다 — 없는 것은 정상이고 "못 잰 것"이 아니다.
|
|
1390
|
+
// 그러나 있는데 못 읽는 것(권한·I/O)은 N 검사가 0줄 돈다는 뜻이라 적는다.
|
|
1391
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(dir, e.code, "N 콘텐츠 페이지 수집");
|
|
1176
1392
|
return [];
|
|
1177
1393
|
}
|
|
1178
1394
|
}
|
|
@@ -1200,11 +1416,28 @@ const DOC_PATH_TOKEN = /^[A-Za-z0-9_.\-/[\]]+\.(?:tsx?|jsx?|mjs|cjs|json|css|md)
|
|
|
1200
1416
|
const DOC_PATH_SKIP_PREFIX = ["doc/", "node_modules/", ".zalkera/", "@", "/", "http"];
|
|
1201
1417
|
|
|
1202
1418
|
/** 문서 본문에서 이 레포의 파일을 가리키는 것으로 판정되는 백틱 토큰. */
|
|
1419
|
+
/**
|
|
1420
|
+
* 문서에서 **좌표로 볼 백틱 토큰**을 고른다.
|
|
1421
|
+
*
|
|
1422
|
+
* ⚠ **`/` 요구는 실수가 아니라 정밀도의 근거다 — 넓히지 마라.** `.prettierrc.json` 처럼 슬래시 없는
|
|
1423
|
+
* 루트 파일이 안 재진다는 것은 사실이고, 그래서 두 가지 확장을 실측으로 재 봤다(2026-08-14):
|
|
1424
|
+
*
|
|
1425
|
+
* ⒜ **이름 좌표를 루트에서 찾기** — 우리 `AGENTS.md` 한 벌에서만 오탐 11건. `Button.tsx`·`page.tsx`·
|
|
1426
|
+
* `globals.css` 는 루트 파일이 아니라 **종류를 가리키는 이름**으로 쓰인다.
|
|
1427
|
+
* ⒝ **이름 좌표를 트리 전체에서 찾기** — 오탐이 문서당 2건으로 줄지만, 남은 6건이 **전부 정당한
|
|
1428
|
+
* 참조**였다: 설치본(`llms.txt`·`sections.ts` → `node_modules/@zalkera/client/`), 소비처가 가질
|
|
1429
|
+
* 파일(`robots.ts`·`sitemap.ts` — llms.txt 는 고객 앱을 설명한다), 남의 레포(`aeo-surface-guarantees.json`
|
|
1430
|
+
* → backend), 런타임 생성물(`robots.txt`). 즉 오탐률 100%다.
|
|
1431
|
+
*
|
|
1432
|
+
* D 규칙은 선언 레포에서 **error** 라 오탐 하나가 남의 빌드를 막는다. 못 잡는 좌표(루트 닷파일)의
|
|
1433
|
+
* 대가는 "죽은 링크 하나"이고, 잘못 잡는 대가는 "멀쩡한 납품물 반려"다. 대칭이 아니다 — 그래서
|
|
1434
|
+
* 슬래시가 있는 것만 좌표로 본다. **이 판단은 값이 아니라 비대칭에서 나온다.**
|
|
1435
|
+
*/
|
|
1203
1436
|
function docPathTokens(text, only) {
|
|
1204
1437
|
const seen = new Set();
|
|
1205
1438
|
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
1206
1439
|
const tok = m[1];
|
|
1207
|
-
if (!tok.includes("/")) continue;
|
|
1440
|
+
if (!tok.includes("/")) continue; // 위 KDoc 의 실측 — 이름만 있는 토큰은 좌표가 아니다
|
|
1208
1441
|
if (!DOC_PATH_TOKEN.test(tok)) continue;
|
|
1209
1442
|
if (DOC_PATH_SKIP_PREFIX.some((p) => tok.startsWith(p))) continue;
|
|
1210
1443
|
if (only && !only.test(tok)) continue;
|
|
@@ -1242,8 +1475,11 @@ function checkDocCoordinates() {
|
|
|
1242
1475
|
|
|
1243
1476
|
const agents = join(repoRoot, "AGENTS.md");
|
|
1244
1477
|
if (fileExists(agents)) {
|
|
1245
|
-
|
|
1246
|
-
|
|
1478
|
+
const agentsText = readSafe(agents, "D1 문서 좌표 검사(AGENTS.md)");
|
|
1479
|
+
for (const tok of docPathTokens(agentsText ?? "")) {
|
|
1480
|
+
const target = containedPath(repoRoot, tok);
|
|
1481
|
+
if (target === null) continue; // 레포 밖 좌표 — 남의 파일시스템을 탐침하지 않는다
|
|
1482
|
+
if (!fileExists(target)) {
|
|
1247
1483
|
sink.push(
|
|
1248
1484
|
`[D1] AGENTS.md 가 없는 파일을 가리킵니다: \`${tok}\` — codegen 이 이 문서를 가장 먼저 ` +
|
|
1249
1485
|
`읽습니다. 실물 경로로 고치거나, 이제 없는 파일이면 경로 표기를 지우세요.`,
|
|
@@ -1255,7 +1491,9 @@ function checkDocCoordinates() {
|
|
|
1255
1491
|
// D2 — 본보기 레포 전용.
|
|
1256
1492
|
let isExemplar = false;
|
|
1257
1493
|
try {
|
|
1258
|
-
isExemplar =
|
|
1494
|
+
isExemplar =
|
|
1495
|
+
JSON.parse(readSafe(join(repoRoot, "package.json"), "본보기 레포 판정") ?? "{}").name ===
|
|
1496
|
+
"@zalkera/storefront-examples";
|
|
1259
1497
|
} catch {
|
|
1260
1498
|
isExemplar = false;
|
|
1261
1499
|
}
|
|
@@ -1263,8 +1501,11 @@ function checkDocCoordinates() {
|
|
|
1263
1501
|
|
|
1264
1502
|
const llms = join(repoRoot, "node_modules", "@zalkera", "client", "llms.txt");
|
|
1265
1503
|
if (!fileExists(llms)) return; // 미설치 — 없는 것을 센 척하지 않는다.
|
|
1266
|
-
|
|
1267
|
-
|
|
1504
|
+
const llmsText = readSafe(llms, "D2 문서 좌표 검사(llms.txt)");
|
|
1505
|
+
for (const tok of docPathTokens(llmsText ?? "", /^src\/(app|components|lib)\//)) {
|
|
1506
|
+
const target = containedPath(repoRoot, tok);
|
|
1507
|
+
if (target === null) continue; // D1 과 같은 봉쇄
|
|
1508
|
+
if (!fileExists(target)) {
|
|
1268
1509
|
sink.push(
|
|
1269
1510
|
`[D2] llms.txt 가 본보기로 지목한 \`${tok}\` 이 이 레포에 없습니다 — 레시피가 실물을 ` +
|
|
1270
1511
|
`앞질렀거나(공표 먼저) 본보기가 그 파일을 지웠습니다. 어느 쪽인지 확인해 한쪽을 맞추세요.`,
|
|
@@ -1318,7 +1559,10 @@ function isSeoPageFile(file) {
|
|
|
1318
1559
|
|
|
1319
1560
|
// SEO page 에서 금지되는 per-page SSR 유발 패턴들.
|
|
1320
1561
|
const SSR_FORBIDDEN = [
|
|
1321
|
-
{
|
|
1562
|
+
{
|
|
1563
|
+
re: /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/,
|
|
1564
|
+
why: `export const dynamic = "force-dynamic" (전 요청 SSR 강제)`,
|
|
1565
|
+
},
|
|
1322
1566
|
{re: /export\s+const\s+revalidate\s*=\s*0\b/, why: `export const revalidate = 0 (ISR 무력화·매 요청 재생성)`},
|
|
1323
1567
|
{re: /\bcookies\s*\(/, why: `page 레벨 cookies() 호출 (동적 렌더 opt-in — 세션값은 클라이언트 컴포넌트로)`},
|
|
1324
1568
|
{re: /\bheaders\s*\(/, why: `page 레벨 headers() 호출 (동적 렌더 opt-in)`},
|
|
@@ -1326,12 +1570,202 @@ const SSR_FORBIDDEN = [
|
|
|
1326
1570
|
{re: /next\s*:\s*\{[^}]*\brevalidate\s*:\s*0\b/, why: `fetch(..., { next: { revalidate: 0 } }) (ISR 무력화)`},
|
|
1327
1571
|
];
|
|
1328
1572
|
|
|
1573
|
+
const WALK_MAX_DEPTH = 64;
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* 서빙 빌드가 **소스 복사에서 버리는** 이름들 — `build.sh` 의 `tar --exclude` 거울이다.
|
|
1577
|
+
* 저쪽이 늘거나 줄면 여기도 같이 고쳐야 한다. 갈리면 "빌드엔 실리는데 검사는 안 되는" 자리가 생긴다.
|
|
1578
|
+
*/
|
|
1579
|
+
const BUILD_DROPS = new Set(["node_modules", ".next", ".git"]);
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* **나무를 훑는 자리는 여기 하나다.** 파일마다 `onFile(전체경로, 이름)` 을 부른다.
|
|
1583
|
+
*
|
|
1584
|
+
* 종전엔 훑기가 **네 벌**이었다(`walk`·X1 의 `collect`·X3 의 `collectSources`·`collectSourceFiles`).
|
|
1585
|
+
* 그래서 `walk` 하나만 고쳤을 때 나머지 셋은 옛 결함을 그대로 들고 있었다 — 같은 패치를 네 번
|
|
1586
|
+
* 덧대는 대신 훑기를 하나로 모은다. 규칙마다 다른 것은 **무엇을 줍느냐**이지 **어떻게 걷느냐**가 아니다.
|
|
1587
|
+
*
|
|
1588
|
+
* ## 디렉터리 심링크를 만나면 — **realpath 가 어느 구역인가**로 정한다
|
|
1589
|
+
*
|
|
1590
|
+
* | realpath 위치 | 처분 | 왜 |
|
|
1591
|
+
* | --- | --- | --- |
|
|
1592
|
+
* | **소스 루트 안** | 따라가지 않음 | 그 실디렉터리는 **어차피 제 자리에서 훑는다**. 별칭으로 또 들어가면 같은 파일을 두 번 세거나, 더 나쁘게 **별칭 경로로 보고**한다 |
|
|
1593
|
+
* | 레포 루트 안 · 소스 루트 밖 | **따라간다** | `src/app/shared -> ../../shared` 같은 정당한 공유 코드. 안 따라가면 그 안의 E1(시크릿)이 통째로 사라진다 |
|
|
1594
|
+
* | 레포 루트 **밖** | 따라가지 않고 **적는다** | 우리 도구가 남의 파일을 대신 읽어 주지 않는다. 납품 zip 은 신뢰 밖 코드이고 `unzip` 은 심링크를 복원한다 |
|
|
1595
|
+
*
|
|
1596
|
+
* ⚠ **첫 칸이 이 구조의 존재 이유다.** 초판은 전역 `visited` 집합 하나로 순환만 끊었는데, 그것이
|
|
1597
|
+
* **형제 별칭까지 잘라 냈다**(심의 실측): `src/aaa -> app` 이 있으면 `aaa` 를 먼저 만난 순간 실제
|
|
1598
|
+
* `src/app` 의 realpath 가 이미 visited 라 **원본 가지가 통째로 스킵**되고, 파일은 `src/aaa/...` 로만
|
|
1599
|
+
* 보고된다. 첫 세그먼트로 `app` 을 판정하는 규칙(C1·C1b·[isLayoutFile]·S3 배선)이 전부 침묵하고
|
|
1600
|
+
* `❌ rc=1` 이 `✅ rc=0` 으로 뒤집힌다. 더 나쁜 것은 **판정이 `readdirSync` 순서에 달렸다**는 점이다 —
|
|
1601
|
+
* `aaa` 는 깨지고 `zzz` 는 멀쩡했다. 관문이 비결정적이면 그것은 관문이 아니다.
|
|
1602
|
+
*
|
|
1603
|
+
* ⚠ 심의 초안이 제안한 "`withFileTypes` + `isDirectory()` 로 심링크를 **전부** 건너뛰기"도 채택하지
|
|
1604
|
+
* 않았다 — 그러면 둘째 칸이 죽어 `src/app/shared -> ../../shared` 안의 E1 2건이 `rc=1` → `rc=0` 으로
|
|
1605
|
+
* 뒤집힌다(실측). 크래시를 **빈 선반**으로 바꾸는 처방이다. 구역을 나누면 셋 다 산다.
|
|
1606
|
+
*
|
|
1607
|
+
* 순환은 **조상 사슬**로 끊는다(전역 집합이 아니라) — 둘째 칸을 따라간 뒤 그 안에서 되돌아오는 경우가
|
|
1608
|
+
* 남기 때문이다. 깊이 상한은 최후의 안전망이지 순환 차단 수단이 아니다(상한으로 버티면 같은 위반이
|
|
1609
|
+
* 64줄 찍힌다 — 실측). 그리고 **못 읽은 자리는 적는다** — 조용한 `catch {}` 는 관문에서 통과로 읽힌다.
|
|
1610
|
+
*
|
|
1611
|
+
* @param dir 시작 디렉터리. 없으면(ENOENT/ENOTDIR) 조용히 끝난다 — 선택적 디렉터리가 많다.
|
|
1612
|
+
* @param onFile 파일마다 불린다. 디렉터리 판단·순환 차단은 여기서 신경 쓸 필요가 없다.
|
|
1613
|
+
* @param what 못 읽었을 때 사람에게 보일 말("X1 라우트 수집" 처럼 **어느 검사가** 못 돌았는지).
|
|
1614
|
+
*/
|
|
1615
|
+
function walkTree(dir, onFile, what) {
|
|
1616
|
+
const step = (d, depth, ancestors) => {
|
|
1617
|
+
if (depth > WALK_MAX_DEPTH) {
|
|
1618
|
+
markUnmeasured(d, "EDEPTH", `디렉터리 깊이 ${WALK_MAX_DEPTH} 초과 — ${what}`);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
let names;
|
|
1622
|
+
try {
|
|
1623
|
+
names = readdirSync(d);
|
|
1624
|
+
} catch (e) {
|
|
1625
|
+
// 없는 디렉터리는 "못 잰 것"이 아니다 — 선택적 좌표가 대부분이다.
|
|
1626
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(d, e.code, what);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
for (const name of names) {
|
|
1630
|
+
const full = join(d, name);
|
|
1631
|
+
// **빌드가 버리는 것만 건너뛴다 — 그 밖은 전부 훑는다.**
|
|
1632
|
+
//
|
|
1633
|
+
// ⚠ 종전엔 이 셋을 **깊이 무관하게 이름으로** 건너뛰었다. 그런데 서빙 복사는 최상위만
|
|
1634
|
+
// 버린다(`build.sh`: `tar --exclude='./node_modules' --exclude='./.next' --exclude='./.git'`
|
|
1635
|
+
// — `./` 접두가 최상위에 못박는다). 즉 `src/app/node_modules/widget.tsx` 는 **빌드에 실제로
|
|
1636
|
+
// 실리는데 검사기만 안 봤다.** 심의 실측: 같은 E1 위반이 정상 위치에서는 관문 exit 1,
|
|
1637
|
+
// `node_modules` 라는 이름의 평범한 디렉터리 안에서는 exit 5(next build 까지 진행).
|
|
1638
|
+
// 디렉터리 이름 한 칸만 바꾸면 세 관문(서빙·납품검수·CI)이 동시에 열렸다.
|
|
1639
|
+
//
|
|
1640
|
+
// 그래서 판정을 이름이 아니라 **자리**로 한다: 레포 루트 바로 아래의 그 셋만 건너뛴다.
|
|
1641
|
+
// 그 아래 것은 빌드에 실리므로 검사 대상이다 — 잣대가 "번들에 들어가는가"이지
|
|
1642
|
+
// "이름이 무엇인가"가 아니다. (`.git` 은 비용 때문에도 뺀다 — 심의 실측 +59%.)
|
|
1643
|
+
if (BUILD_DROPS.has(name) && dirname(full) === repoRootDir) continue;
|
|
1644
|
+
let stat;
|
|
1645
|
+
try {
|
|
1646
|
+
stat = statSync(full);
|
|
1647
|
+
} catch (e) {
|
|
1648
|
+
// 끊어진 심링크·권한 없는 자리. 종전엔 여기서 **스택트레이스로 죽었다.**
|
|
1649
|
+
markUnmeasured(full, e.code, `파일 정보 — ${what}`);
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
if (!stat.isDirectory()) {
|
|
1653
|
+
onFile(full, name);
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
// 심링크가 아니면 realpath 를 물을 이유가 없다 — `realpathSync` 는 `statSync` 의 **11.5배**
|
|
1657
|
+
// 이고 **경로 깊이에 선형**이라(심의 실측: depth2 17us → depth64 281us) 디렉터리가 많은
|
|
1658
|
+
// 트리에서 총비용이 제곱으로 번진다. 실디렉터리는 부모의 실경로에 이름만 붙이면 된다.
|
|
1659
|
+
let real = join(currentReal(ancestors), name);
|
|
1660
|
+
if (isSymlink(full)) {
|
|
1661
|
+
try {
|
|
1662
|
+
real = realpathSync(full);
|
|
1663
|
+
} catch (e) {
|
|
1664
|
+
// ⚠ **이 줄에는 회귀 시험이 없다 — 도달 형상을 못 만들었다.** 바로 위 `statSync` 가
|
|
1665
|
+
// 이미 전체 경로를 해석하므로, 여기까지 왔으면 realpath 도 대개 성공한다(끊어진
|
|
1666
|
+
// 심링크·ELOOP·권한 부족은 전부 statSync 에서 먼저 잡힌다 — 실측). 남는 것은 훑는
|
|
1667
|
+
// 도중 트리가 바뀌는 경합뿐이라 시험으로 고정할 수 없다.
|
|
1668
|
+
// 그래서 이 줄은 **심층방어**다. 없는 커버리지를 있다고 적지 않기 위해 여기 적는다.
|
|
1669
|
+
markUnmeasured(full, e.code, `실경로 해석 — ${what}`);
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
// ⓐ 소스 루트 안 — 따라가지 않는다. 그 실디렉터리는 제 자리에서 훑는다.
|
|
1673
|
+
if (within(real, root)) continue;
|
|
1674
|
+
// ⓑ 레포 밖 — 따라가지 않고 적는다. 못 잰 것이지 통과가 아니다.
|
|
1675
|
+
if (!within(real, repoRootDir)) {
|
|
1676
|
+
markUnmeasured(full, "EXDEV", `레포 밖을 가리키는 심링크라 따라가지 않았습니다 — ${what}`);
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
if (ancestors.has(real)) continue; // 조상으로 되돌아가는 순환
|
|
1681
|
+
step(full, depth + 1, new Set(ancestors).add(real));
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
let rootReal;
|
|
1685
|
+
try {
|
|
1686
|
+
rootReal = realpathSync(dir);
|
|
1687
|
+
} catch (e) {
|
|
1688
|
+
// 시작 좌표의 부재는 정상(선택적 디렉터리가 많다). 그 밖의 사유는 적는다.
|
|
1689
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(dir, e.code, what);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
step(dir, 0, new Set([rootReal]));
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** `p` 가 `base` 와 같거나 그 아래인가. 경로 문자열이 아니라 **경계**를 묻는다. */
|
|
1696
|
+
function within(p, base) {
|
|
1697
|
+
if (p === base) return true;
|
|
1698
|
+
const rel = relative(base, p);
|
|
1699
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* 레포 안으로 **봉쇄된** 경로 해석. 소스 내용(문서 좌표·import 지정자)에서 나온 경로 문자열을
|
|
1704
|
+
* 파일시스템에 대기 **전에** 반드시 통과시킨다.
|
|
1705
|
+
*
|
|
1706
|
+
* 왜 필요한가 — 이 검사기는 고객 자기 레포에서만 도는 물건이 아니다. `build.sh` 가 서빙 샌드박스에서
|
|
1707
|
+
* **테넌트가 올린 소스**에 `--gate` 로 돌리고, 그 출력은 `/_gate-status` logTail 로 테넌트에게 돌아간다.
|
|
1708
|
+
* 즉 입력·탐침·회신이 다 갖춰져 있어, 경계가 없으면 우리 빌드 박스에 대한 **존재 오라클**(D1/D2)과
|
|
1709
|
+
* **임의 파일 읽기**(import 그래프)가 된다. 셋 다 심의가 통제군을 세워 실증했다.
|
|
1710
|
+
*
|
|
1711
|
+
* 접두 문자열로 막으려던 판이 있었다 — `tok.startsWith("..")` 는 선행 `..` 만 막고 `a/../../x` 에
|
|
1712
|
+
* 뚫린다. 경로 경계는 **해석 후 봉쇄**로만 선다. `walkTree` 가 쓰는 규칙과 같은 것이다.
|
|
1713
|
+
*
|
|
1714
|
+
* 경계는 **소스 루트가 아니라 레포 루트**다. `src/app/page.tsx` 가 `../../content` 를 부르는 것은
|
|
1715
|
+
* 정당한 간선이고(본보기 4벌 실물), 소스 루트로 좁히면 그 간선을 끊는다.
|
|
1716
|
+
*
|
|
1717
|
+
* 반환: 봉쇄된 절대경로 · 레포 밖으로 나가면 `null`. `null` 은 "없다"가 아니라 **"우리가 판정할
|
|
1718
|
+
* 자리가 아니다"** 다 — 탐침하지도, 부재를 단정하지도 않는다.
|
|
1719
|
+
*/
|
|
1720
|
+
function containedPath(from, spec) {
|
|
1721
|
+
const target = resolve(from, spec);
|
|
1722
|
+
if (!within(target, repoRootDir)) return null; // ⓐ 문자열 해석 단계의 이탈(`..`·절대경로)
|
|
1723
|
+
// ⓑ 심링크 이탈 — `resolve` 는 심링크를 풀지 않으므로 `link/x.ts`(link → /etc)는 ⓐ 를 통과한다.
|
|
1724
|
+
// 성분마다 확인한다. 없는 성분을 만나면 그 경로는 애초에 존재하지 않으니 이탈도 아니다.
|
|
1725
|
+
let cur = repoRootDir;
|
|
1726
|
+
for (const part of relative(repoRootDir, target).split(sep)) {
|
|
1727
|
+
if (part === "") continue;
|
|
1728
|
+
cur = join(cur, part);
|
|
1729
|
+
let st;
|
|
1730
|
+
try {
|
|
1731
|
+
st = lstatSync(cur);
|
|
1732
|
+
} catch {
|
|
1733
|
+
return target; // 없음 — 죽은 좌표인지는 부르는 쪽 규칙이 판단한다
|
|
1734
|
+
}
|
|
1735
|
+
if (st.isSymbolicLink()) {
|
|
1736
|
+
let real;
|
|
1737
|
+
try {
|
|
1738
|
+
real = realpathSync(cur);
|
|
1739
|
+
} catch {
|
|
1740
|
+
return null;
|
|
1741
|
+
}
|
|
1742
|
+
if (!within(real, repoRootDir)) return null;
|
|
1743
|
+
cur = real;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return cur;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** 심링크인가. `lstatSync` 는 따라가지 않는다. 못 물으면 아니라고 답한다(뒤의 `statSync` 가 잡는다). */
|
|
1750
|
+
function isSymlink(p) {
|
|
1751
|
+
try {
|
|
1752
|
+
return lstatSync(p).isSymbolicLink();
|
|
1753
|
+
} catch {
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/** 현재 하강 사슬의 끝 = 지금 디렉터리의 실경로. Set 은 삽입 순서를 지킨다. */
|
|
1759
|
+
function currentReal(ancestors) {
|
|
1760
|
+
let last;
|
|
1761
|
+
for (const a of ancestors) last = a;
|
|
1762
|
+
return last;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1329
1765
|
function walk(dir) {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
if (statSync(full).isDirectory()) {
|
|
1334
|
-
walk(full);
|
|
1766
|
+
walkTree(
|
|
1767
|
+
dir,
|
|
1768
|
+
(full, name) => {
|
|
1335
1769
|
// ⚠ **관문 대가가 있다**(심의 실측): `.mjs` 를 훑으면서 `preview-runner --gate` 의 종료코드가
|
|
1336
1770
|
// **0 → 1** 로 바뀐다(`[E3]` 예시 키 `oqsk_test_…` 를 오류층으로 잡는다). 실제 서빙 코퍼스는
|
|
1337
1771
|
// rc=0 을 유지해 오늘의 폭발반경은 0 이지만, **발행이 남의 관문 계약을 바꾼다**는 사실은
|
|
@@ -1343,13 +1777,15 @@ function walk(dir) {
|
|
|
1343
1777
|
// ⚠ `.mjs`·`.cjs` 도 훑는다. 종전엔 빠져 있어 **그 파일에서는 규칙이 아예 안 돌았다**
|
|
1344
1778
|
// (심의 관찰 — 검사기가 조용한 것이지 안전한 것이 아니다). Next 설정·서버 유틸이 그 확장자로
|
|
1345
1779
|
// 오는 레포가 있다.
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1780
|
+
if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) {
|
|
1781
|
+
check(full);
|
|
1782
|
+
if (isLayoutFile(full)) layoutFiles.push(full);
|
|
1783
|
+
} else if (name.endsWith(".css")) {
|
|
1784
|
+
cssFiles.push(full);
|
|
1785
|
+
}
|
|
1786
|
+
},
|
|
1787
|
+
"소스 파일 수집(규약 검사 본체)",
|
|
1788
|
+
);
|
|
1353
1789
|
}
|
|
1354
1790
|
|
|
1355
1791
|
// ── C1b: layout 폭발반경 게이트 ──────────────────────────────────
|
|
@@ -1372,7 +1808,10 @@ function isLayoutFile(file) {
|
|
|
1372
1808
|
if (!/^(layout|template)\.(ts|tsx|js|jsx)$/.test(basename(file))) return false;
|
|
1373
1809
|
const segments = relative(root, file).split(sep);
|
|
1374
1810
|
if (segments[0] !== "app") return false;
|
|
1375
|
-
return !segments
|
|
1811
|
+
return !segments
|
|
1812
|
+
.slice(1, -1)
|
|
1813
|
+
.map(normalizeSegment)
|
|
1814
|
+
.some((s) => SEO_EXCLUDE_SEGMENTS.has(s));
|
|
1376
1815
|
}
|
|
1377
1816
|
|
|
1378
1817
|
/**
|
|
@@ -1409,7 +1848,32 @@ const DYNAMIC_API = [
|
|
|
1409
1848
|
* (SiteHeader 주석이 정확히 그렇다). `//` 는 URL(`http://`)과 구분하려고 앞 문자가 `:` 이 아닐 때만.
|
|
1410
1849
|
*/
|
|
1411
1850
|
function stripComments(src) {
|
|
1412
|
-
|
|
1851
|
+
// ⚠ 종전엔 `src.replace(/\/\*[\s\S]*?\*\//g, " ")` 였다. **닫히지 않은 `/*` 가 많은 파일에서
|
|
1852
|
+
// 2차로 폭발한다** — 오프너마다 파일 끝까지 훑고 실패한 뒤 한 칸 전진해 다시 훑는다.
|
|
1853
|
+
// 실측(심의 성능축 F5): 배증할 때마다 4배 — 15KB 17.6ms · 30KB 103ms · 60KB 275ms ·
|
|
1854
|
+
// 120KB 1320ms, 검사기 전체로는 240KB 17.6초 · 600KB 110초. 이 레포는 이미 두 번 ReDoS 를
|
|
1855
|
+
// 냈고 `REGEX_SCAN_CAP` 으로 옆 자리를 막아 뒀는데, **상한 없는 형제가 그대로 남아 있었다.**
|
|
1856
|
+
//
|
|
1857
|
+
// 선형 스캐너로 바꾸되 **의미는 종전과 같다**: 닫는 `*/` 가 없으면 그 `/*` 는 주석으로 치지
|
|
1858
|
+
// 않고 원문을 남긴다(정규식이 닫힘을 요구했으므로 같은 결과다). 뒤에 `*/` 가 하나도 없으면
|
|
1859
|
+
// 그 뒤의 어떤 `/*` 도 닫힐 수 없으므로 거기서 멈추는 것이 안전하다.
|
|
1860
|
+
let out = "";
|
|
1861
|
+
let i = 0;
|
|
1862
|
+
for (;;) {
|
|
1863
|
+
const open = src.indexOf("/*", i);
|
|
1864
|
+
if (open === -1) {
|
|
1865
|
+
out += src.slice(i);
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
const close = src.indexOf("*/", open + 2);
|
|
1869
|
+
if (close === -1) {
|
|
1870
|
+
out += src.slice(i); // 미종료 — 원문 유지(종전 정규식과 동일)
|
|
1871
|
+
break;
|
|
1872
|
+
}
|
|
1873
|
+
out += src.slice(i, open) + " ";
|
|
1874
|
+
i = close + 2;
|
|
1875
|
+
}
|
|
1876
|
+
return out.replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
|
|
1413
1877
|
}
|
|
1414
1878
|
|
|
1415
1879
|
/**
|
|
@@ -1443,9 +1907,12 @@ function resolveImport(spec, fromFile) {
|
|
|
1443
1907
|
else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
|
|
1444
1908
|
else return null;
|
|
1445
1909
|
|
|
1910
|
+
// ⚠ 후보 **마다** 봉쇄한다 — `base` 만 검사하면 마지막 성분이 심링크인 `src/lib/x.ts` 가 샌다.
|
|
1446
1911
|
for (const cand of [base, ...[".ts", ".tsx", ".js", ".jsx"].flatMap((e) => [base + e, join(base, "index" + e)])]) {
|
|
1912
|
+
const safe = containedPath(repoRootDir, cand);
|
|
1913
|
+
if (safe === null) continue; // 레포 밖 — 따라가지 않는다
|
|
1447
1914
|
try {
|
|
1448
|
-
if (statSync(
|
|
1915
|
+
if (statSync(safe).isFile()) return safe;
|
|
1449
1916
|
} catch {
|
|
1450
1917
|
/* 다음 후보 */
|
|
1451
1918
|
}
|
|
@@ -1507,12 +1974,10 @@ function dynamicApiReachableFrom(entry) {
|
|
|
1507
1974
|
if (seen.has(file)) continue;
|
|
1508
1975
|
seen.add(file);
|
|
1509
1976
|
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
continue;
|
|
1515
|
-
}
|
|
1977
|
+
// `readSafe` 를 태운다 — 조용한 `catch {}` 는 못 읽은 것을 통과로 만든다(이 파일의 처분).
|
|
1978
|
+
// 크기 상한도 여기서 듣는다(아래 MAX_READ_BYTES).
|
|
1979
|
+
const raw = readSafe(file, `C1 서버 그래프 판독(${relative(repoRootDir, file)})`);
|
|
1980
|
+
if (raw === null) continue;
|
|
1516
1981
|
if (isClientBoundary(raw)) continue; // 클라이언트 경계 — 여기서 끊는다.
|
|
1517
1982
|
|
|
1518
1983
|
const text = stripComments(raw); // 주석만 제거(문자열 값·import 지정자 보존)
|
|
@@ -1548,7 +2013,9 @@ function checkLayoutBlastRadius() {
|
|
|
1548
2013
|
|
|
1549
2014
|
// 면제는 **범인 파일**에 붙인다 — layout 에 붙이면 그 아래 전부가 한 번에 뚫린다.
|
|
1550
2015
|
// 신 마커 zalkera- + 구 마커(oneq-/oneque-)를 양형 수용한다(리네임 이행기).
|
|
1551
|
-
const allow =
|
|
2016
|
+
const allow = (readSafe(file, "C1b 면제 마커 판독") ?? "").match(
|
|
2017
|
+
/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/,
|
|
2018
|
+
);
|
|
1552
2019
|
const path = chain.map((f) => relative(process.cwd(), f)).join(" → ");
|
|
1553
2020
|
const detail =
|
|
1554
2021
|
`${relative(process.cwd(), layout)} 이 ${why} 에 도달한다 → ${path}. ` +
|
|
@@ -1590,12 +2057,10 @@ function checkThemeWiring() {
|
|
|
1590
2057
|
if (STYLE_MODE !== "declared") return; // 선언 없는 레포의 주입 부재는 결함이 아니라 정상이다.
|
|
1591
2058
|
|
|
1592
2059
|
const globalsCss = join(root, "app", "globals.css");
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
return; // 파일 부재는 S3 가 이미 error 로 잡는다 — 같은 사실을 두 번 외치지 않는다.
|
|
1598
|
-
}
|
|
2060
|
+
// 부재는 S3 가 이미 error 로 잡는다(같은 사실을 두 번 외치지 않는다). 그러나 **있는데 못 읽는 것**은
|
|
2061
|
+
// 다른 사실이고, 조용히 return 하면 S8 이 통째로 사라진 채 ✅ 가 난다(심의 실측).
|
|
2062
|
+
const css = readSafe(globalsCss, "S8 테마 토큰 검사(globals.css)");
|
|
2063
|
+
if (css === null) return;
|
|
1599
2064
|
|
|
1600
2065
|
// S8-a — 토큰 정의.
|
|
1601
2066
|
if (!/@theme\b/.test(css) || !/--color-primary\s*:/.test(css)) {
|
|
@@ -1617,7 +2082,8 @@ function checkThemeWiring() {
|
|
|
1617
2082
|
});
|
|
1618
2083
|
if (!rootLayout) return; // root layout 부재는 C1 계열의 몫.
|
|
1619
2084
|
|
|
1620
|
-
const raw =
|
|
2085
|
+
const raw = readSafe(rootLayout, "테마 주입 배선 검사(root layout)");
|
|
2086
|
+
if (raw === null) return;
|
|
1621
2087
|
const src = stripComments(raw); // 주석은 거짓말을 한다 — 앵커를 코드에서만 찾는다.
|
|
1622
2088
|
const injects = /parseThemeColors\s*\(/.test(src) && /<html[^>]*\sstyle=/.test(src);
|
|
1623
2089
|
if (injects) return;
|
|
@@ -1644,12 +2110,9 @@ function checkSectionCoverage() {
|
|
|
1644
2110
|
if (!Array.isArray(contract) || contract.length === 0) return;
|
|
1645
2111
|
|
|
1646
2112
|
const rendererPath = join(root, "components/sections/SectionRenderer.tsx");
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
} catch {
|
|
1651
|
-
return; // 렌더러가 없는 구조(BYO) — 검사 대상 아님.
|
|
1652
|
-
}
|
|
2113
|
+
// 렌더러가 없는 구조(BYO)는 검사 대상이 아니다. 못 읽는 것은 그것과 다른 사실이다.
|
|
2114
|
+
const src = readSafe(rendererPath, "섹션 렌더러 계약 검사");
|
|
2115
|
+
if (src === null) return;
|
|
1653
2116
|
const cases = new Set([...src.matchAll(/case\s+"([A-Z_]+)"/g)].map((m) => m[1]));
|
|
1654
2117
|
const missing = contract.map((c) => c.type).filter((t) => !cases.has(t));
|
|
1655
2118
|
if (missing.length > 0) {
|
|
@@ -1746,10 +2209,10 @@ function checkContentContract() {
|
|
|
1746
2209
|
|
|
1747
2210
|
// N1 — 매니페스트. 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다(계약 rev 4 `contentFile.manifest`).
|
|
1748
2211
|
const manifestPath = join(repoRoot, "content", "index.ts");
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2212
|
+
// ⚠ 종전엔 EACCES 도 "없습니다"로 보고했다 — 있는데 못 읽는 것을 부재로 말하면 고칠 자리가 어긋난다.
|
|
2213
|
+
const manifest = readSafe(manifestPath, "N1 콘텐츠 매니페스트");
|
|
2214
|
+
if (manifest === null && existsSync(manifestPath)) return; // 못 읽음은 markUnmeasured 가 적었다
|
|
2215
|
+
if (manifest === null) {
|
|
1753
2216
|
sink.push(
|
|
1754
2217
|
`[N1] ${rel(manifestPath)} 가 없습니다 — 콘텐츠 매니페스트(정적 import)가 없으면 ` +
|
|
1755
2218
|
`dev 에서 json 을 고쳐도 화면이 안 바뀌고(HMR 미발화), 빌드 산출물에 콘텐츠가 안 실립니다.`,
|
|
@@ -1761,9 +2224,7 @@ function checkContentContract() {
|
|
|
1761
2224
|
// import 로 읽으면 없는 파일을 찾는 오탐이 난다(실제로 이 검사를 넣자마자 그렇게 죽었다).
|
|
1762
2225
|
// 이 레포가 C1b·S8 에서 이미 밟은 함정과 같은 것이라 같은 처방을 쓴다.
|
|
1763
2226
|
const declaredSlugs = manifest
|
|
1764
|
-
? new Set(
|
|
1765
|
-
[...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([\w-]+)\.json["']/g)].map((m) => m[1]),
|
|
1766
|
-
)
|
|
2227
|
+
? new Set([...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([\w-]+)\.json["']/g)].map((m) => m[1]))
|
|
1767
2228
|
: null;
|
|
1768
2229
|
if (declaredSlugs) {
|
|
1769
2230
|
for (const file of files) {
|
|
@@ -1777,7 +2238,9 @@ function checkContentContract() {
|
|
|
1777
2238
|
}
|
|
1778
2239
|
for (const slug of declaredSlugs) {
|
|
1779
2240
|
if (!files.some((f) => basename(f, ".json") === slug)) {
|
|
1780
|
-
sink.push(
|
|
2241
|
+
sink.push(
|
|
2242
|
+
`[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`,
|
|
2243
|
+
);
|
|
1781
2244
|
}
|
|
1782
2245
|
}
|
|
1783
2246
|
}
|
|
@@ -1786,15 +2249,19 @@ function checkContentContract() {
|
|
|
1786
2249
|
warnIfContractMissing(contract);
|
|
1787
2250
|
|
|
1788
2251
|
for (const file of files) {
|
|
2252
|
+
const pageText = readSafe(file, "N2 콘텐츠 페이지 판독");
|
|
2253
|
+
if (pageText === null) continue; // 못 읽음은 파싱 실패가 아니다
|
|
1789
2254
|
let page;
|
|
1790
2255
|
try {
|
|
1791
|
-
page = JSON.parse(
|
|
2256
|
+
page = JSON.parse(pageText);
|
|
1792
2257
|
} catch (e) {
|
|
1793
2258
|
sink.push(`[N2] ${rel(file)}: JSON 파싱 실패 — ${e.message}`);
|
|
1794
2259
|
continue;
|
|
1795
2260
|
}
|
|
1796
2261
|
if (page == null || typeof page !== "object" || Array.isArray(page)) {
|
|
1797
|
-
sink.push(
|
|
2262
|
+
sink.push(
|
|
2263
|
+
`[N2] ${rel(file)}: 최상위가 객체여야 합니다(현재 ${Array.isArray(page) ? "배열" : typeof page}).`,
|
|
2264
|
+
);
|
|
1798
2265
|
continue;
|
|
1799
2266
|
}
|
|
1800
2267
|
|
|
@@ -1811,7 +2278,9 @@ function checkContentContract() {
|
|
|
1811
2278
|
continue;
|
|
1812
2279
|
}
|
|
1813
2280
|
if (typeof section.type !== "string" || section.type.trim() === "") {
|
|
1814
|
-
sink.push(
|
|
2281
|
+
sink.push(
|
|
2282
|
+
`[N4] ${at}: type 은 비어 있지 않은 문자열이어야 합니다 — 렌더러가 이 섹션을 통째로 건너뜁니다.`,
|
|
2283
|
+
);
|
|
1815
2284
|
continue;
|
|
1816
2285
|
}
|
|
1817
2286
|
if ("sortOrder" in section) {
|
|
@@ -1847,7 +2316,12 @@ function checkContentContract() {
|
|
|
1847
2316
|
sink.push(`[N5] ${at}: "${path}" 는 public 루트 절대 경로 문자열이어야 합니다.`);
|
|
1848
2317
|
continue;
|
|
1849
2318
|
}
|
|
1850
|
-
if (
|
|
2319
|
+
if (
|
|
2320
|
+
!value.startsWith("/") ||
|
|
2321
|
+
value.startsWith("//") ||
|
|
2322
|
+
value.includes("\\") ||
|
|
2323
|
+
value.split("/").includes("..")
|
|
2324
|
+
) {
|
|
1851
2325
|
sink.push(
|
|
1852
2326
|
`[N5] ${at}: "${path}" = ${JSON.stringify(value)} — 레포 public/ 루트 절대 경로만 그려집니다` +
|
|
1853
2327
|
`(원격 URL·상대 경로·경로 탈출은 렌더에서 통째로 떨어집니다).`,
|
|
@@ -1857,7 +2331,9 @@ function checkContentContract() {
|
|
|
1857
2331
|
try {
|
|
1858
2332
|
statSync(join(repoRoot, "public", value));
|
|
1859
2333
|
} catch {
|
|
1860
|
-
sink.push(
|
|
2334
|
+
sink.push(
|
|
2335
|
+
`[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`,
|
|
2336
|
+
);
|
|
1861
2337
|
}
|
|
1862
2338
|
}
|
|
1863
2339
|
// N5 — 계약 필수 참조.
|
|
@@ -1894,7 +2370,15 @@ function checkContentContract() {
|
|
|
1894
2370
|
}
|
|
1895
2371
|
|
|
1896
2372
|
function check(file) {
|
|
1897
|
-
|
|
2373
|
+
let src;
|
|
2374
|
+
try {
|
|
2375
|
+
src = readFileSync(file, "utf8");
|
|
2376
|
+
} catch (e) {
|
|
2377
|
+
// 권한 없는 파일. 종전엔 여기서 스택트레이스로 죽었다(심의 처방이 못 닫은 자리 — 그 처방은
|
|
2378
|
+
// `readdirSync` 축만 봤다).
|
|
2379
|
+
markUnmeasured(file, e.code, "파일 읽기");
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
1898
2382
|
const rel = relative(process.cwd(), file);
|
|
1899
2383
|
// ⚠ **파일 머리만 본다(심의 차단 4).** 종전은 `/m` 플래그로 **원문 전체의 행머리**를 봤고,
|
|
1900
2384
|
// 행머리에 `"use client"` 를 담은 **템플릿 리터럴 한 줄**이 있으면 SEO 페이지가 클라이언트로
|
|
@@ -1907,14 +2391,17 @@ function check(file) {
|
|
|
1907
2391
|
// 서버 클라이언트 싱글턴 존재 확인 — create{Zalkera,Oneque}Client 호출(구 심볼 수용).
|
|
1908
2392
|
if (/create(?:Zalkera|Oneque)Client\s*\(/.test(src)) {
|
|
1909
2393
|
singletonFound = true;
|
|
1910
|
-
if (isClient)
|
|
2394
|
+
if (isClient)
|
|
2395
|
+
servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
|
|
1911
2396
|
}
|
|
1912
2397
|
|
|
1913
2398
|
if (isClient) {
|
|
1914
2399
|
// E1: 값 import(= import type 아님)로 @zalkera/client 를 들여옴(구 @oneque/client 도 잡는다).
|
|
1915
2400
|
const valueImport = /^import\s+(?!type\s)[^;]*from\s+["']@(?:zalkera|oneque)\/client["']/m.test(src);
|
|
1916
2401
|
if (valueImport) {
|
|
1917
|
-
servingSink().push(
|
|
2402
|
+
servingSink().push(
|
|
2403
|
+
`[E1] ${rel}: "use client" 파일에서 @zalkera/client 를 값으로 import 한다 (타입은 \`import type\` 으로).`,
|
|
2404
|
+
);
|
|
1918
2405
|
}
|
|
1919
2406
|
// E2: 서버 싱글턴(lib/zalkera) import(구 lib/oneque 도 잡는다).
|
|
1920
2407
|
if (/from\s+["'][^"']*lib\/(?:zalkera|oneque)["']/.test(src)) {
|
|
@@ -2065,8 +2552,9 @@ function checkStyleWiring() {
|
|
|
2065
2552
|
}
|
|
2066
2553
|
});
|
|
2067
2554
|
if (rootLayout) {
|
|
2068
|
-
const
|
|
2069
|
-
|
|
2555
|
+
const layoutText = readSafe(rootLayout, "S3 globals.css import 검사");
|
|
2556
|
+
const src = stripComments(layoutText ?? "");
|
|
2557
|
+
if (layoutText !== null && !/import\s+["'][^"']*globals\.css["']/.test(src)) {
|
|
2070
2558
|
s3.push(
|
|
2071
2559
|
`[S3] ${relative(process.cwd(), rootLayout)} 이 globals.css 를 import 하지 않습니다 ` +
|
|
2072
2560
|
`(\`import "./globals.css"\`). 배선이 없으면 Tailwind CSS·테마 토큰이 로드되지 않습니다.`,
|
|
@@ -2285,7 +2773,11 @@ function findMutationHandlers(code) {
|
|
|
2285
2773
|
// ⒞ `export {POST}` · `export {handler as POST}` — 본문을 못 따라간다.
|
|
2286
2774
|
for (const m of code.matchAll(/export\s*\{([^}]*)\}/g)) {
|
|
2287
2775
|
for (const part of m[1].split(",")) {
|
|
2288
|
-
const name = part
|
|
2776
|
+
const name = part
|
|
2777
|
+
.trim()
|
|
2778
|
+
.split(/\s+as\s+/)
|
|
2779
|
+
.pop()
|
|
2780
|
+
?.trim();
|
|
2289
2781
|
if (name && new RegExp(`^(${METHODS})$`).test(name)) push(name, null);
|
|
2290
2782
|
}
|
|
2291
2783
|
}
|
|
@@ -2374,22 +2866,17 @@ function judgeGuardPlacement(rawBody) {
|
|
|
2374
2866
|
// 로 쓰면 플랫폼 존에서 형제 테넌트가 통과한다.
|
|
2375
2867
|
function checkCrossOriginGuards() {
|
|
2376
2868
|
const routes = [];
|
|
2377
|
-
const collect = (dir) =>
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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
|
-
};
|
|
2869
|
+
const collect = (dir) =>
|
|
2870
|
+
walkTree(
|
|
2871
|
+
dir,
|
|
2872
|
+
(full, name) => {
|
|
2873
|
+
// ⚠ `route.js`·`.jsx`·`.mjs` 도 라우트다. 종전엔 `.ts`·`.tsx` 만 봐서 **가드 규칙(X1)이
|
|
2874
|
+
// 그 파일들에서 통째로 침묵**했다(I2 는 도는데 X1 만 안 도는 비대칭 · 심의 실측).
|
|
2875
|
+
// 구식인 Pages Router 쪽(X1p)이 오히려 `.js` 를 보고 있었다.
|
|
2876
|
+
if (/^route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) routes.push(full);
|
|
2877
|
+
},
|
|
2878
|
+
"X1 라우트 수집(교차출처 가드)",
|
|
2879
|
+
);
|
|
2393
2880
|
// ⚠ `root` 는 **레포 루트가 아니라 소스 루트**(`./src`)다(82행). 초판은 `src/app/api` 도
|
|
2394
2881
|
// 함께 걸었는데 그건 `./src/src/app/api` 라 존재하지 않는 죽은 경로였다.
|
|
2395
2882
|
//
|
|
@@ -2401,12 +2888,13 @@ function checkCrossOriginGuards() {
|
|
|
2401
2888
|
|
|
2402
2889
|
const exempted = [];
|
|
2403
2890
|
for (const file of routes) {
|
|
2404
|
-
const code =
|
|
2891
|
+
const code = readSafe(file, "X1 교차출처 가드 검사");
|
|
2892
|
+
if (code === null) continue;
|
|
2405
2893
|
const rel = relative(root, file);
|
|
2406
2894
|
// ⚠ **가드 축(X1·X1p)만 JSX 강등을 끈다.** JSX 파일에서 낱말 뒤 정규식을 나눗셈으로 두면
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2895
|
+
// 그 안의 백틱이 살아나 파일을 삼켜 **가드가 안 보인다**(미탐). 산문 삼킴은 I2·X3 축에만
|
|
2896
|
+
// 해로우므로, 두 축을 갈라 각각에 맞는 사본을 준다(심의 처방 · 미탐 4·오탐 2 동시 해소).
|
|
2897
|
+
const handlers = findMutationHandlers(stripLiterals(code, false));
|
|
2410
2898
|
if (!handlers.length) continue;
|
|
2411
2899
|
|
|
2412
2900
|
// 면제 마커는 **파일 상단**에만 둔다 — 아무 데나 허용하면 주석·문자열 안의 한 줄로
|
|
@@ -2441,7 +2929,8 @@ function checkCrossOriginGuards() {
|
|
|
2441
2929
|
|
|
2442
2930
|
// X2 — 읽기 GET 면제는 "CORS 헤더가 없다"에 의존한다. 그 전제가 깨지면 면제도 깨진다.
|
|
2443
2931
|
for (const file of routes) {
|
|
2444
|
-
const code =
|
|
2932
|
+
const code = readSafe(file, "X2 CORS 헤더 검사");
|
|
2933
|
+
if (code === null) continue;
|
|
2445
2934
|
if (/Access-Control-Allow-Origin/i.test(code)) {
|
|
2446
2935
|
crossOriginSink().push(
|
|
2447
2936
|
`[X2] ${relative(root, file)} 가 CORS 헤더를 답니다 — 읽기 GET 을 가드에서 빼는 근거가` +
|
|
@@ -2460,25 +2949,19 @@ function checkCrossOriginGuards() {
|
|
|
2460
2949
|
// **fail-open** 했다(심의 실측: 소각 삭제 + 리네임 = 통과). `./src/src/app/api` 이후
|
|
2461
2950
|
// 좌표가 죽어 검사가 사라진 **세 번째 사례**라 정의를 찾아가는 쪽으로 바꾼다.
|
|
2462
2951
|
const sources = [];
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
}
|
|
2468
|
-
|
|
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);
|
|
2952
|
+
walkTree(
|
|
2953
|
+
root,
|
|
2954
|
+
(full, name) => {
|
|
2955
|
+
if (/\.tsx?$/.test(name)) sources.push(full);
|
|
2956
|
+
},
|
|
2957
|
+
"X3 소스 수집(OAuth state 소각)",
|
|
2958
|
+
);
|
|
2477
2959
|
|
|
2478
2960
|
let consumeDef = null;
|
|
2479
2961
|
let usesStateCookie = false;
|
|
2480
2962
|
for (const file of sources) {
|
|
2481
|
-
const raw =
|
|
2963
|
+
const raw = readSafe(file, "파일 읽기");
|
|
2964
|
+
if (raw === null) continue;
|
|
2482
2965
|
const src = stripLiterals(raw, holdsJsx(file, raw)); // X3 — 산문 삼킴을 막아야 하는 축
|
|
2483
2966
|
if (/\bconsumeOAuthState\s*\(/.test(src)) usesStateCookie = true;
|
|
2484
2967
|
// 정의 형태를 묻지 않는다 — function 선언·화살표 상수 둘 다.
|
|
@@ -2567,20 +3050,15 @@ function pagesRoot() {
|
|
|
2567
3050
|
return null;
|
|
2568
3051
|
}
|
|
2569
3052
|
|
|
2570
|
-
/** 디렉터리 아래 소스 파일 전량(재귀).
|
|
3053
|
+
/** 디렉터리 아래 소스 파일 전량(재귀). 걷는 규율은 [walkTree] 하나에 모여 있다. */
|
|
2571
3054
|
function collectSourceFiles(dir, into = []) {
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
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
|
-
}
|
|
3055
|
+
walkTree(
|
|
3056
|
+
dir,
|
|
3057
|
+
(full, name) => {
|
|
3058
|
+
if (/\.(ts|tsx|js|jsx|mjs)$/.test(name)) into.push(full);
|
|
3059
|
+
},
|
|
3060
|
+
"C1p·X1p 소스 수집(Pages Router 축)",
|
|
3061
|
+
);
|
|
2584
3062
|
return into;
|
|
2585
3063
|
}
|
|
2586
3064
|
|
|
@@ -2602,7 +3080,8 @@ function checkPagesRouter() {
|
|
|
2602
3080
|
|
|
2603
3081
|
for (const file of collectSourceFiles(pagesDir)) {
|
|
2604
3082
|
const rel = relative(process.cwd(), file);
|
|
2605
|
-
const raw =
|
|
3083
|
+
const raw = readSafe(file, "파일 읽기");
|
|
3084
|
+
if (raw === null) continue;
|
|
2606
3085
|
const text = stripComments(raw);
|
|
2607
3086
|
const stem = basename(file).replace(/\.[^.]+$/, "");
|
|
2608
3087
|
|
|
@@ -2680,16 +3159,18 @@ function checkEnvFiles() {
|
|
|
2680
3159
|
let names;
|
|
2681
3160
|
try {
|
|
2682
3161
|
names = readdirSync(repoRoot).filter((n) => n === ".env" || n.startsWith(".env."));
|
|
2683
|
-
} catch {
|
|
3162
|
+
} catch (e) {
|
|
3163
|
+
// 레포 루트를 못 읽으면 E3 가 **한 줄도 안 돈다.** 조용히 return 하면 ✅ 가 난다.
|
|
3164
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTDIR") markUnmeasured(repoRoot, e.code, "E3 환경파일 스캔");
|
|
2684
3165
|
return;
|
|
2685
3166
|
}
|
|
2686
3167
|
for (const name of names.sort()) {
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
3168
|
+
// ⚠ 여기가 E3 의 **다른 절반**이다. 디렉터리는 읽히는데 파일이 안 읽히는 경우가 있고, 종전엔
|
|
3169
|
+
// 그때 조용히 `continue` 해서 **시크릿을 한 줄도 안 보고 `✅ 통과`** 가 났다(심의 셋이 모두 지목).
|
|
3170
|
+
// 디렉터리 읽기만 고치고 파일 읽기를 남겨 두면 같은 구멍이 그대로다 — 실제로 한 번 그랬다.
|
|
3171
|
+
const text = readSafe(join(repoRoot, name), `E3 환경파일 판독(${name})`);
|
|
3172
|
+
if (text === null) continue;
|
|
3173
|
+
const lines = text.split("\n");
|
|
2693
3174
|
for (const line of lines) {
|
|
2694
3175
|
if (/^\s*#/.test(line)) continue; // 주석 — `.env.example` 이 금지 규약을 문장으로 적어 뒀다.
|
|
2695
3176
|
const m = line.match(PUBLIC_SECRET_ENV);
|
|
@@ -2744,12 +3225,9 @@ function checkServingOutputContract() {
|
|
|
2744
3225
|
// 설정 파일이 여럿이면 어느 것이 유효한지 Next 의 해석 순서에 달렸다 — 못 가르는 자리라 스킵한다.
|
|
2745
3226
|
if (found.length !== 1) return;
|
|
2746
3227
|
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
} catch {
|
|
2751
|
-
return;
|
|
2752
|
-
}
|
|
3228
|
+
const configText = readSafe(found[0], "O1 서빙 산출물 계약 검사(next.config)");
|
|
3229
|
+
if (configText === null) return;
|
|
3230
|
+
const text = stripComments(configText);
|
|
2753
3231
|
if (!isStaticallyReadableConfig(text)) return; // 동적 조립 — 잠자코 스킵.
|
|
2754
3232
|
|
|
2755
3233
|
const name = basename(found[0]);
|
|
@@ -2773,8 +3251,16 @@ function checkServingOutputContract() {
|
|
|
2773
3251
|
servingOutputSink().push(`[O1] ${name}: output 설정이 없습니다 — ${advice}`);
|
|
2774
3252
|
}
|
|
2775
3253
|
|
|
3254
|
+
// ⚠ `statSync` 만으로는 **파일을 소스 루트로 준 경우**를 못 가른다. `walkTree` 는 시작 좌표의
|
|
3255
|
+
// ENOTDIR 를 "선택적 하위 디렉터리 부재"로 관용하므로, 그대로 두면 훑을 것이 0개인 채 `✅ rc=0` 으로
|
|
3256
|
+
// 끝난다(심의 실측 — 구판은 크래시로나마 관문을 닫았다). 선택은 하위 좌표의 성질이지 시작 좌표의
|
|
3257
|
+
// 성질이 아니다. 여기서 틀리면 **검사를 한 줄도 안 돌고 통과**가 난다.
|
|
2776
3258
|
try {
|
|
2777
|
-
statSync(root)
|
|
3259
|
+
if (!statSync(root).isDirectory()) {
|
|
3260
|
+
console.error(`디렉터리가 아닙니다(소스 디렉터리를 주십시오 · 보통 ./src): ${root}`);
|
|
3261
|
+
console.error(`검사를 시작하지 못했습니다 — 통과가 아닙니다.`);
|
|
3262
|
+
process.exit(2);
|
|
3263
|
+
}
|
|
2778
3264
|
} catch {
|
|
2779
3265
|
console.error(`디렉터리를 찾을 수 없습니다: ${root}`);
|
|
2780
3266
|
process.exit(2);
|
|
@@ -2827,7 +3313,8 @@ function checkManualCarrier() {
|
|
|
2827
3313
|
|
|
2828
3314
|
checkManualCarrier(); // W-LLMS — zip 이 나르는 명세가 설치본과 같은가(fail-soft).
|
|
2829
3315
|
|
|
2830
|
-
if (!singletonFound)
|
|
3316
|
+
if (!singletonFound)
|
|
3317
|
+
warnings.push(`[W1] createZalkeraClient 싱글턴을 찾지 못했습니다 — 서버 사이드 호출 패턴이 있는지 확인하세요.`);
|
|
2831
3318
|
|
|
2832
3319
|
console.log(
|
|
2833
3320
|
`스타일 규약 모드: ${STYLE_MODE}` +
|
|
@@ -2848,8 +3335,50 @@ console.log(
|
|
|
2848
3335
|
for (const w of warnings) console.warn("⚠️ " + w);
|
|
2849
3336
|
for (const e of errors) console.error("❌ " + e);
|
|
2850
3337
|
|
|
3338
|
+
// ── 못 잰 것 — 통과도 위반도 아닌 셋째 칸 ───────────────────────────────
|
|
3339
|
+
//
|
|
3340
|
+
// 문면은 `build.sh` §exit4 의 2층 관례를 그대로 쓴다: 첫 줄은 **업로더가 할 수 있는 일**,
|
|
3341
|
+
// 다음 줄은 개발자·LLM 에 그대로 붙여넣을 지시. 종전엔 이 자리가 스택트레이스이거나 침묵이었다.
|
|
3342
|
+
// 같은 파일이 여러 검사에서 걸리면 줄이 그만큼 늘어난다 — 사람에게는 **자리 수**가 정보다.
|
|
3343
|
+
// 자리별로 접고, 어느 검사가 사라졌는지는 그 아래에 모은다.
|
|
3344
|
+
const unmeasuredByPath = new Map();
|
|
3345
|
+
for (const u of unmeasured) {
|
|
3346
|
+
const key = `${u.path}|${u.code}`;
|
|
3347
|
+
if (!unmeasuredByPath.has(key)) unmeasuredByPath.set(key, {...u, whats: new Set()});
|
|
3348
|
+
unmeasuredByPath.get(key).whats.add(u.what);
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
if (unmeasuredByPath.size > 0) {
|
|
3352
|
+
const say = GATE_MODE ? console.error : console.warn;
|
|
3353
|
+
const mark = GATE_MODE ? "❌" : "⚠️ ";
|
|
3354
|
+
say(`\n${mark} 읽지 못한 자리 ${unmeasuredByPath.size}곳 — 이 자리들은 **검사하지 않았습니다(통과가 아닙니다).**`);
|
|
3355
|
+
say(` 이 소스를 만든 개발자에게 아래 줄을 그대로 전달하십시오.`);
|
|
3356
|
+
for (const u of unmeasuredByPath.values()) {
|
|
3357
|
+
say(` · ${u.path} [${u.code}] — 못 돈 검사: ${[...u.whats].join(" · ")}`);
|
|
3358
|
+
}
|
|
3359
|
+
if (GATE_MODE) {
|
|
3360
|
+
// 관문 fail-closed. 오탐 비용을 0 이라고 적었던 종전 주석은 **거짓이었다**(심의 실측): 서빙
|
|
3361
|
+
// 복사는 `tar --no-same-owner` 라 소유권만 정규화하고 **모드는 보존**하며, 빌더는 `--cap-drop ALL`
|
|
3362
|
+
// 이라 root 라도 DAC_OVERRIDE 가 없어 EACCES 가 실제로 난다. 그래도 닫는 것이 맞다 — 이유가
|
|
3363
|
+
// "안 뜬다"가 아니라 **"못 잰 것을 통과로 세지 않는다"** 이기 때문이다.
|
|
3364
|
+
console.error(`\n검사를 끝내지 못했습니다. rc=7(검사 불능) — 규약 위반(rc=1)과 다른 뜻입니다.`);
|
|
3365
|
+
process.exit(7);
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
|
|
2851
3369
|
if (errors.length > 0) {
|
|
2852
3370
|
console.error(`\n${errors.length}개 오류. 스토어프론트 규약 위반을 고치세요 (llms.txt §5 참고).`);
|
|
2853
3371
|
process.exit(1);
|
|
2854
3372
|
}
|
|
2855
|
-
|
|
3373
|
+
// ⚠ **못 잰 것을 요약 줄에 합친다.** 권고 모드에서는 위의 고지 **뒤에** 이 줄이 찍히므로, 여기서
|
|
3374
|
+
// 침묵하면 사람이 마지막에 본 `✅ 통과` 만 기억한다(심의 지적 — verify-zip 커밋이 스스로 적은
|
|
3375
|
+
// "사람은 ✅ 를 기억한다"의 더 나쁜 판이다. 거기서는 ✅ 가 위에 있었고 여기서는 아래에 있다).
|
|
3376
|
+
const tally = [
|
|
3377
|
+
warnings.length ? `경고 ${warnings.length}` : "",
|
|
3378
|
+
unmeasuredByPath.size ? `못 잰 자리 ${unmeasuredByPath.size}` : "",
|
|
3379
|
+
]
|
|
3380
|
+
.filter(Boolean)
|
|
3381
|
+
.join(" · ");
|
|
3382
|
+
console.log(
|
|
3383
|
+
`✅ ${unmeasuredByPath.size ? "잰 범위에서는 통과" : "통과"} — 검사한 규약 위반 없음${tally ? ` (${tally})` : ""}.`,
|
|
3384
|
+
);
|