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