@zalkera/client 0.22.1 → 0.22.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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* AEO 보장 표면
|
|
3
|
+
* AEO 보장 표면 검사기.
|
|
4
4
|
*
|
|
5
5
|
* **개시된 사이트를 크롤해 보장 그래프가 실제로 나오는지 잰다.** 소스를 읽지 않는 것이 요점이다
|
|
6
6
|
* (오너 전제 C): "이 섹션을 써라"는 디자인 자유를 깎지만 "개시된 페이지에서 이 그래프가 나오는가"는
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 잣대는 백엔드 정본 `doc/contracts/aeo-surface-guarantees.json` 이고 이 파일은 그 정본의 **집행자**다.
|
|
10
10
|
* 규범을 여기서 새로 만들지 않는다 — 표에 없는 것은 검사하지 않고, 표에 있는 것은 봐주지 않는다.
|
|
11
11
|
*
|
|
12
|
-
* ── 이 파일이 왜 client 패키지에 사는가
|
|
12
|
+
* ── 이 파일이 왜 client 패키지에 사는가 ────────────────────────
|
|
13
13
|
* 원래 이 검사기는 `storefront-template/scripts/` 에 살았다. 그런데 이제 **사람이 CLI 로 치는 것 말고도**
|
|
14
14
|
* 두 자리에서 돌아야 한다: ⑴ 고객 zip(원래 자리) ⑵ serving-orchestrator 가 발행 직후 자동으로 재는 자리.
|
|
15
15
|
* ⑵의 실행체는 "외부 의존 0 단일 `server.mjs`"라는 속성을 지켜야 해서 검사기를 자기 안에 넣을 수 없고,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* 사본을 하나 더 두면 두 검사기가 조용히 갈라지므로(`lib/site-crawl.mjs` 의 KDoc 과 같은 논거) 정본을
|
|
18
18
|
* 여기로 옮기고 template 의 `scripts/check-aeo-surfaces.mjs` 는 이 bin 을 부르는 **얇은 wrapper** 다.
|
|
19
19
|
*
|
|
20
|
-
* ── 잣대를 어디서 찾는가
|
|
20
|
+
* ── 잣대를 어디서 찾는가 ────────────────────────────────────
|
|
21
21
|
* 이 검사기는 고객 zip 에 실려 나간다. 그런데 **잣대는 실려 나가지 않아서** 고객 기계에서는 기본 경로
|
|
22
22
|
* (형제 `../backend`)가 없어 exit 2 로 죽어 있었다(2026-07-29 실측). 그래서 해석을 3단으로 둔다:
|
|
23
23
|
*
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* (스토어프론트 체크아웃·고객 zip 안에서는 `npm run check:aeo -- …` 가 같은 bin 을 부른다)
|
|
43
43
|
*
|
|
44
44
|
* npx zalkera-aeo-check <사이트URL> --site-wide-only
|
|
45
|
-
* **보장 주장이 없는 사이트**를 재는
|
|
45
|
+
* **보장 주장이 없는 사이트**를 재는 모드. `--category` 를 요구하지 않고
|
|
46
46
|
* siteWide 버킷(robots·sitemap·JSON-LD 절대 URL)만 잰다. 카테고리 required 는 아예 판정하지
|
|
47
47
|
* 않는다 — 어휘 준수를 주장한 적 없는 사이트에 그 잣대를 들이대는 것은 오너 전제 A(디자인 자유)를
|
|
48
48
|
* 어기는 일이다. 발행 직후 자동 검사(orchestrator)가 전 사이트에 쓰는 모드가 이것이다.
|
|
@@ -75,7 +75,7 @@ import {FUNCTIONAL_SEGMENTS, crawlPages, makeClassifier} from "../lib/site-crawl
|
|
|
75
75
|
/**
|
|
76
76
|
* 판정 형식이 바뀌면 올린다 — 스냅샷에 박혀서 "어느 잣대로 잰 판정인가"가 사후에도 남는다.
|
|
77
77
|
*
|
|
78
|
-
* 2 = `--site-wide-only` 모드와 스냅샷의 `mode` 필드
|
|
78
|
+
* 2 = `--site-wide-only` 모드와 스냅샷의 `mode` 필드 추가. 기존 카테고리 판정의 잣대는
|
|
79
79
|
* 하나도 안 바뀌었지만, 스냅샷 형식에 필드가 늘었고 그 필드를 읽는 소비자(promote 게이트·백엔드 ingest)가
|
|
80
80
|
* "구판 스냅샷인가"를 이 수로 가른다.
|
|
81
81
|
*/
|
|
@@ -106,7 +106,7 @@ const siteUrl = argv.find((a) => !a.startsWith("--") && /^https?:\/\//.test(a));
|
|
|
106
106
|
const categories = flagAll("category");
|
|
107
107
|
const printOnly = argv.includes("--print-guarantees");
|
|
108
108
|
/**
|
|
109
|
-
* 무주장
|
|
109
|
+
* 무주장 모드. **카테고리 판정을 아예 하지 않는다** — 끄는 것이 아니라 안 하는 것이다.
|
|
110
110
|
* 보장 어휘를 주장한 적 없는 사이트에 그 잣대를 대면, 그 사이트가 어떤 스택으로 짰든 red 가 쏟아지고
|
|
111
111
|
* 그 red 는 사실이 아니라 **잣대를 잘못 고른 결과**다(오너 전제 A). 그래서 카테고리 축은 판정에서 빠지고
|
|
112
112
|
* 사이트 축(robots·sitemap·절대 URL)만 남는다 — 그 셋은 어떤 디자인 선택과도 무관한 기계 가독 최소치다.
|
|
@@ -638,7 +638,7 @@ const results = [];
|
|
|
638
638
|
|
|
639
639
|
if (siteWideOnly) {
|
|
640
640
|
/**
|
|
641
|
-
* 무주장 판정 — **사이트
|
|
641
|
+
* 무주장 판정 — **사이트 축만**. 카테고리 required·conditional·planned 는 물론
|
|
642
642
|
* `negative`(부정 보장)도 여기서는 안 잰다: 부정 보장은 "우리 어휘를 쓰는 팩이 자사 별점을 달지
|
|
643
643
|
* 않는다"는 우리 팩의 규율이고, 남의 사이트가 자기 후기 마크업을 어떻게 다는지는 우리가 판정할
|
|
644
644
|
* 자리가 아니다(전제 A). B-2 에서 주장 앵커가 생기면 그 사이트에는 잰다.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* ⚠ I1 은 T4 에 오류로 **승격 예정**이다. I2 는 그 승격에 끌려가지 않도록 **싱크가
|
|
18
18
|
* 분리돼 있다**(`clientIpDeclarationSink` — 승격 대상 아님). 합치지 마라.
|
|
19
19
|
* W1 클라이언트 싱글턴 파일이 하나도 없음 → 서버 사이드 호출 패턴 미구현 의심.
|
|
20
|
-
* C1 ISR-우선 게이트
|
|
20
|
+
* C1 ISR-우선 게이트 — SEO 라우트 page 가 per-page SSR(동적 렌더)을 강제하면 실패.
|
|
21
21
|
* codegen 산출물이 홈·목록·상세·콘텐츠 페이지를 동적SSR 로 만들면 CI 를 red 로 만들어 미배포.
|
|
22
22
|
* 정당화된 예외는 파일에 `// zalkera-allow-dynamic: <이유>` 마커를 두면 경고로 강등된다.
|
|
23
23
|
* C1b layout 폭발반경 게이트 — layout/template 이 **import 로 도달하는 서버 모듈**에서 동적 API 를
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
* S5 warning — src/app/globals.css 외의 .css 파일 존재(단일 CSS 원칙).
|
|
38
38
|
* C2 error — 섹션 렌더러 switch 가 계약(@zalkera/client SECTION_CONTRACT)을 덮지 못함. 계약에 있는
|
|
39
39
|
* 타입을 렌더러가 모르면 그 섹션은 **조용히 안 그려진다**(미지 타입 스킵이 계약이라 에러도 안 난다).
|
|
40
|
-
* S8 error — **표현 계약(L1) 배선 부재**(declared
|
|
40
|
+
* S8 error — **표현 계약(L1) 배선 부재**(declared 전용). globals.css 의 `@theme`+`--color-primary`
|
|
41
41
|
* 정의와 root layout 의 테마 주입(`parseThemeColors(` 호출 + `<html>` style)을 센다. 이게 없으면 콘솔의
|
|
42
42
|
* "말로 색 바꾸기"가 **성공 보고를 내고 화면은 그대로**인 거짓성공이 된다 — validator 가 여태 L1 의
|
|
43
|
-
* 심장을 한 번도 안
|
|
43
|
+
* 심장을 한 번도 안 봤다.
|
|
44
44
|
* S6 error — 남의 토큰 어휘(shadcn 기본 변수명) 클래스 사용. shadcn 소스를 발췌해 올 때 재작성 표를
|
|
45
45
|
* 적용하지 않으면 `bg-card`·`text-muted-foreground` 같은 **정의되지 않은 토큰**을 참조해 색이 조용히
|
|
46
|
-
* 빠진다. 우리 @theme 이 토큰 정본이고 shadcn 변수층은 반입하지
|
|
46
|
+
* 빠진다. 우리 @theme 이 토큰 정본이고 shadcn 변수층은 반입하지 않는다.
|
|
47
47
|
* N1~N5 — **콘텐츠 파일 계약**(어휘 계약 rev 4 `contentFile` · 선언 `zalkera.content`). 사이트의
|
|
48
48
|
* 얼굴(페이지·섹션·문구·이미지 선택·내비)이 `content/` 아래 json 으로 살 때, 그 파일이 조용히
|
|
49
49
|
* 안 그려지는 형상을 잡는다. 아래 '콘텐츠 조건화' 참고 — **선언한 레포에서만 error** 다.
|
|
50
50
|
*
|
|
51
|
-
* ── 스택 조건화
|
|
51
|
+
* ── 스택 조건화 ──────────────────────────────────────────
|
|
52
52
|
* E1/E2/W1(헤드리스 계약·스택무관)·C1/C1b(Next App Router 전제)는 **상시** 적용한다.
|
|
53
53
|
* S1~S5(Tailwind+토큰 전제)는 레포의 스택 선언에 따라 3모드로 게이팅한다(선두에서 1회 판정):
|
|
54
54
|
*
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
* inferred 선언 부재 + deps/devDeps 에 tailwindcss 있음 W W W W W (위생 모드)
|
|
58
58
|
* none 그 외(다른 선언값·tailwindcss 도 없음) – – – – – (스킵)
|
|
59
59
|
*
|
|
60
|
-
* ── 콘텐츠 조건화
|
|
60
|
+
* ── 콘텐츠 조건화 ─────────────────────────────────
|
|
61
61
|
* 같은 3모드를 `content` 축에 그대로 적용한다. **계약을 안 지킨 레포도 돌아야 한다**(오너 정본 전제 1
|
|
62
62
|
* "강제할 수 없다") — 문구를 tsx 에 직접 든 레포도 개시·발행·codegen 이 전부 정상이고, 검사는 레포가
|
|
63
63
|
* **스스로 선언했을 때만** 격상된다.
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
* none 그 외(다른 선언값 · content 디렉터리 없음) – (스킵)
|
|
69
69
|
*
|
|
70
70
|
* N0 **`zalkera.content` 가 모르는 값** — 모드는 `none`(N1~N5 스킵)이지만 경고 한 줄을 낸다. 둘을 가른다:
|
|
71
|
-
* ⑴ 은퇴한 값 `"sections-db"` — 그 선언을 든 레포는 얼굴의 정본이 어디에도 없다(어휘 rev 7
|
|
71
|
+
* ⑴ 은퇴한 값 `"sections-db"` — 그 선언을 든 레포는 얼굴의 정본이 어디에도 없다(어휘 rev 7).
|
|
72
72
|
* ⑵ 그 밖의 미지 값(오탈자 `"sourse"` 류) — **스킵한다는 사실 자체를 말한다.** 유효값이 하나로 줄어
|
|
73
|
-
* 한 글자 오타가 N 규칙 전체를 조용히 끄는 스위치가 됐다(
|
|
74
|
-
* 어느 쪽도 error 가 아니다 — 남의 선언값을 우리가 해석해 막으면 그것이 어휘
|
|
73
|
+
* 한 글자 오타가 N 규칙 전체를 조용히 끄는 스위치가 됐다(경고 ⑦).
|
|
74
|
+
* 어느 쪽도 error 가 아니다 — 남의 선언값을 우리가 해석해 막으면 그것이 어휘 강제다.
|
|
75
75
|
* N1 content/index.ts(매니페스트) 부재 — 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다.
|
|
76
76
|
* N2 content/pages/*.json 파싱 실패 또는 최상위가 객체 아님.
|
|
77
77
|
* N3 매니페스트와 파일의 어긋남 — 파일은 있는데 매니페스트에 없으면 **그 페이지는 존재하지 않는다**
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
*
|
|
87
87
|
* O1 warning(**관문 모드에서도 경고 — 승격 영구 금지**) — `next.config` 에 `output: 'standalone'` 이
|
|
88
88
|
* 없다. 잘커라가 서빙하는 소스는 빌드가 `.next/standalone` 자기완결 산출물을 내야 하는데
|
|
89
|
-
* (우리 박스는 `node server.js` 로
|
|
89
|
+
* (우리 박스는 `node server.js` 로 띄운다), **설정 문자열은 그 사실을 재지 못한다** —
|
|
90
90
|
* 조건부 조립이면 키가 있어도 산출이 안 나오고 없어도 나올 수 있다(X1 과 같은 양방향 거짓).
|
|
91
91
|
* 그래서 정적으로 **확신할 수 있는 형상**(리터럴 객체 한 벌)에서만 말하고 동적이면 잠자코
|
|
92
92
|
* 스킵한다. 판정은 산출물이 사실인 자리에서만 한다(`verify-zip` ⑧ · CI · 서빙 게이트 exit 4).
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
* D1 AGENTS.md 가 **없는 파일을 가리킴**. codegen 이 가장 먼저 읽는 문서라 죽은 좌표는 곧 탐색 토큰이다
|
|
95
95
|
* (2026-07-30 기준선 실측: 낡은 좌표 때문에 에이전트가 콘텐츠 계약 대신 라우트를 새로 짰다).
|
|
96
96
|
* D2 설치된 `@zalkera/client` 의 llms.txt 가 **본보기로 지목한 경로**가 이 레포에 없음 — 레시피가
|
|
97
|
-
* 실물을 앞지른
|
|
97
|
+
* 실물을 앞지른 상태. **본보기 레포에서만** 돈다.
|
|
98
98
|
* D1·D2 는 우리 계약(styling·content 중 하나)을 선언한 레포에서 error, 그 밖에선 warning 이다.
|
|
99
99
|
*
|
|
100
100
|
* declared 는 Managed 토큰 계약 라인이라 S2/S4 를 error 로 격상한다(리터럴 색·인라인 style 이
|
|
@@ -107,13 +107,13 @@
|
|
|
107
107
|
* ── 이 파일의 거처 (2026-08-01 이관) ──────────────────────────────────────────
|
|
108
108
|
*
|
|
109
109
|
* 종전에는 `zalkera-storefront-examples/scripts/` 에만 있었다. 그런데 **서빙 빌드가 이 검사기를
|
|
110
|
-
* 돌려야** 하는 자리가 생겼고(우리가 서빙하는 사이트에서 시크릿 노출·동적 SSR 강제를 막는다
|
|
111
|
-
*
|
|
110
|
+
* 돌려야** 하는 자리가 생겼고(우리가 서빙하는 사이트에서 시크릿 노출·동적 SSR 강제를 막는다),
|
|
111
|
+
* 그러려면 컨테이너가 이 파일을 가져야 한다.
|
|
112
112
|
*
|
|
113
113
|
* 서빙 이미지에 사본을 굽는 길은 **정본을 둘로 만든다**. 그 병은 이 코드베이스가 하루에도 몇 번씩
|
|
114
114
|
* 겪는 것이다(계약 rev 가 올랐는데 검사기가 옛 키를 보던 일, 같은 관례를 두 곳이 다르게 구현하던 일).
|
|
115
115
|
* 그래서 **`@zalkera/client` 가 bin 으로 배송한다** — `zalkera-aeo-check` 가 이미 그 자리에 있고
|
|
116
|
-
|
|
116
|
+
*, 소스 검사기도 같은 자리에 두면 정본이 하나로 남는다.
|
|
117
117
|
*
|
|
118
118
|
* 부수 효과가 하나 더 있다: **고객이 자기 손으로 같은 검사기를 돌릴 수 있다.** 종전에는 우리 예제
|
|
119
119
|
* 레포에만 있어서, 자기 소스를 받아 고치는 사람이 쓸 방법이 없었다.
|
|
@@ -262,7 +262,7 @@ function resolveRepoRoot(sourceRoot) {
|
|
|
262
262
|
const repoRootDir = resolveRepoRoot(root);
|
|
263
263
|
|
|
264
264
|
/**
|
|
265
|
-
* shadcn 기본 토큰 어휘(재작성 표의
|
|
265
|
+
* shadcn 기본 토큰 어휘(재작성 표의 좌변). 우리 @theme 에 없는 이름이라 클래스가 생성되지
|
|
266
266
|
* 않는다 — 색이 빠진 채로 조용히 배포되는 종류의 사고라 declared 모드에서 error 다.
|
|
267
267
|
* 주의: 우리 `muted` 는 **글자색**이라 `bg-muted`(shadcn 은 배경)와 의미가 다르다.
|
|
268
268
|
*/
|
|
@@ -439,7 +439,7 @@ function hardcodedColorClasses(text) {
|
|
|
439
439
|
*/
|
|
440
440
|
/** 서버 전용임이 이름에 드러난 환경변수에 `NEXT_PUBLIC_` 이 붙은 형태. */
|
|
441
441
|
const PUBLIC_SECRET_ENV = /\bNEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|PRIVATE_KEY|STOREFRONT_KEY)[A-Z0-9_]*\b/g;
|
|
442
|
-
/** 스토어프론트 서버 시크릿 키 리터럴(
|
|
442
|
+
/** 스토어프론트 서버 시크릿 키 리터럴(`oqsk_…`). 소스에 박히면 그 자체로 유출이다. */
|
|
443
443
|
const STOREFRONT_KEY_LITERAL = /\boqsk_[A-Za-z0-9_-]{8,}/g;
|
|
444
444
|
|
|
445
445
|
/** 이 텍스트에서 발견된 시크릿 노출 형태의 사람용 사유 문자열들. */
|
|
@@ -468,13 +468,13 @@ const cssFiles = [];
|
|
|
468
468
|
let singletonFound = false;
|
|
469
469
|
|
|
470
470
|
/**
|
|
471
|
-
* 스타일 규약 모드 판정
|
|
471
|
+
* 스타일 규약 모드 판정 — validator 선두에서 1회. srcDir 상위 최근접 package.json 의
|
|
472
472
|
* `zalkera.styling` 선언을 읽는다(구 `oneque.styling` 도 수용 — 리네임 이행기):
|
|
473
473
|
* declared : zalkera.styling === "tailwind-tokens" → 토큰 계약 모드(S2/S4 error 격상)
|
|
474
474
|
* inferred : 선언 부재 + deps/devDeps 에 tailwindcss 있음 → 위생 모드(S 전부 warning)
|
|
475
475
|
* none : 그 외(다른 선언값·tailwindcss 도 없음) → S 전부 스킵
|
|
476
476
|
* package.json 을 못 찾거나 파싱 실패하면 none(안전 — S 안 들이댄다).
|
|
477
|
-
* 백엔드는 스택을 모른다 — 선언은 레포 안에 살고 validator 가 현장에서
|
|
477
|
+
* 백엔드는 스택을 모른다 — 선언은 레포 안에 살고 validator 가 현장에서 읽는다.
|
|
478
478
|
*/
|
|
479
479
|
/** 모르는 `zalkera.styling` 값. 모드는 `none` 이지만 조용히 넘어가지 않는다. */
|
|
480
480
|
let styleDeclarationNote = null;
|
|
@@ -574,7 +574,7 @@ function servingSink() {
|
|
|
574
574
|
* 없고 상용 실측도 0건이라, 관문으로 켜도 아무도 안 막힌다. 그래서 그 둘만 `servingSink` 를 쓴다.
|
|
575
575
|
*/
|
|
576
576
|
/**
|
|
577
|
-
* **X 축(X1·X1p·X3) 전용 목적지 — 승격은 영구
|
|
577
|
+
* **X 축(X1·X1p·X3) 전용 목적지 — 승격은 영구 금지다**.
|
|
578
578
|
*
|
|
579
579
|
* [clientIpSink] 와 지금은 같은 배열로 가지만 **함수가 다르다.** 이유는 [clientIpDeclarationSink] 와 같다:
|
|
580
580
|
* 남이 I1 을 `servingSink()` 로 승격시키는 날, 싱크를 공유하면 X 축이 **딸려 올라간다.**
|
|
@@ -589,8 +589,8 @@ function servingSink() {
|
|
|
589
589
|
* 실제로 뜨지 않으면 그냥 방치다. ⚠ 오늘 이 경고는 `/api/system` 에만 보인다. 고칠 수 있는 당사자
|
|
590
590
|
* (파트너)가 못 보는 상태이고, 그 노출은 아직 안 만들었다.
|
|
591
591
|
*
|
|
592
|
-
* 그리고 X3 는 **경고다.**
|
|
593
|
-
*
|
|
592
|
+
* 그리고 X3 는 **경고다.** "X3 가 strict 회귀를 error 로 막는다"는 말은 사실이 아니다 —
|
|
593
|
+
* 이 검사기는 경고만 낸다.
|
|
594
594
|
*/
|
|
595
595
|
function crossOriginSink() {
|
|
596
596
|
return warnings;
|
|
@@ -613,13 +613,13 @@ function crossOriginSink() {
|
|
|
613
613
|
* **같은 파일 안 단순 변수 경유**까지만 좇는다. 잡지 못한 형상은 검사기가 조용한 것이지 안전한 것이 아니다.
|
|
614
614
|
*
|
|
615
615
|
* **왜 지금은 경고인가.** 관문으로 켜면 첫 동작이 **상용 자산 반려**다(우리 예제 레포와 상용 사이트가
|
|
616
|
-
* 아직 이 관용구를 쓴다 — 우리 교본이 그렇게 가르쳤기 때문이다). 자산을 먼저 일소하고
|
|
616
|
+
* 아직 이 관용구를 쓴다 — 우리 교본이 그렇게 가르쳤기 때문이다). 자산을 먼저 일소하고
|
|
617
617
|
* 그 뒤에 [servingSink] 로 옮긴다. **승격 지점은 여기 한 줄**이다.
|
|
618
618
|
*
|
|
619
619
|
* 그리고 경고는 **받아 줄 곳을 가리켜야** 방치로 안 끝난다 — 메시지가 `visitorIp()` 를 지목한다.
|
|
620
620
|
*/
|
|
621
621
|
function clientIpSink() {
|
|
622
|
-
return warnings; //
|
|
622
|
+
return warnings; // 자산 일소 뒤 `servingSink()` 로 승격한다.
|
|
623
623
|
}
|
|
624
624
|
|
|
625
625
|
/**
|
|
@@ -639,7 +639,7 @@ function clientIpDeclarationSink() {
|
|
|
639
639
|
/**
|
|
640
640
|
* **서빙 산출물 계약 축(O)의 목적지 — 관문 모드에서도 경고다. 승격은 영구 금지다.**
|
|
641
641
|
*
|
|
642
|
-
* 계약 자체는
|
|
642
|
+
* 계약 자체는 사실이다: *"잘커라가 서빙하는 소스는 빌드가 `.next/standalone` 자기완결
|
|
643
643
|
* 산출물을 내야 한다."* 우리 박스는 `next start` 가 아니라 그 산출물을 `node server.js` 로 띄운다.
|
|
644
644
|
*
|
|
645
645
|
* **그런데 이 검사기는 그 사실을 잴 수 없다.** 여기서 읽을 수 있는 것은 `next.config` 라는 **설정 문자열**
|
|
@@ -652,7 +652,7 @@ function clientIpDeclarationSink() {
|
|
|
652
652
|
* 그래서 이 축은 **경고까지만**이고, 관문은 산출물이 정의상 사실인 자리에만 둔다 — `verify-zip` ⑧
|
|
653
653
|
* (빌드 뒤 실물)·examples CI·서빙 박스 `build.sh` exit 4. 셋 다 **이미 지불한 빌드**를 읽는다.
|
|
654
654
|
*
|
|
655
|
-
* 그리고 하나 더: **자체 호스팅(BYO)에는 이 요건의 근거가 아예 없다**(
|
|
655
|
+
* 그리고 하나 더: **자체 호스팅(BYO)에는 이 요건의 근거가 아예 없다**(강제의 근거는
|
|
656
656
|
* 누가 서빙하는가다). Vercel·정적 export 로 사는 레포에 관문을 들이대면 멀쩡한 소스를 반려하게 된다.
|
|
657
657
|
* 그래서 메시지도 조건부로 말한다("잘커라 호스팅에 올릴 소스라면").
|
|
658
658
|
*
|
|
@@ -730,7 +730,7 @@ function firstHopAdoption(text) {
|
|
|
730
730
|
* **0 이 아니라서 눈치채기 어렵다**(이 축이 조용히 죽는 이유가 그것이다).
|
|
731
731
|
*
|
|
732
732
|
* ⚠ **오늘 상용에서는 선언해도 승격되지 않는다.** 백엔드는 유효한 스토어프론트 키가 확인된 요청에서만
|
|
733
|
-
* 선언을 승격하는데(`StorefrontKeyResolver`), 상용은
|
|
733
|
+
* 선언을 승격하는데(`StorefrontKeyResolver`), 상용은 아직 무키다 — 백엔드 자신이
|
|
734
734
|
* `DeclaredClientIp` 에 그렇게 적어 뒀다. 그래도 지금 넣어야 한다: 컷오버 날 **코드 변경 0 으로 자동
|
|
735
735
|
* 실효**하고, 소스는 그때 고칠 수 없는 자리(고객 레포)에 가 있다.
|
|
736
736
|
*
|
|
@@ -1321,7 +1321,7 @@ function missingClientIp(rawWithBom, jsx = false) {
|
|
|
1321
1321
|
const STYLE_MODE = detectStyleMode(root);
|
|
1322
1322
|
|
|
1323
1323
|
/**
|
|
1324
|
-
* 콘텐츠 규약 모드 판정
|
|
1324
|
+
* 콘텐츠 규약 모드 판정 — [detectStyleMode] 와 **같은 모양**이다.
|
|
1325
1325
|
*
|
|
1326
1326
|
* 다른 점 하나: 추론의 근거가 의존성이 아니라 **형상**이다(`content/pages/*.json` 실재). 콘텐츠는
|
|
1327
1327
|
* 패키지로 안 오므로 deps 로는 알 수 없고, 그 형상을 갖췄다는 것 자체가 "이 계약을 쓰는 중"의 신호다.
|
|
@@ -1332,17 +1332,15 @@ const STYLE_MODE = detectStyleMode(root);
|
|
|
1332
1332
|
* none : 그 외 → N 규칙 스킵
|
|
1333
1333
|
*
|
|
1334
1334
|
* **은퇴한 선언값**(`"sections-db"`)은 3모드를 늘리지 않는다 — 판정은 그대로 `none` 이고 경고 한 줄을
|
|
1335
|
-
* 더한다(어휘 rev 7
|
|
1335
|
+
* 더한다(어휘 rev 7). 섹션이 백엔드 DB 에도 살던 시절의 값인데 그 거처가 통째로 퇴역해서,
|
|
1336
1336
|
* 그 선언을 든 레포는 **얼굴의 정본이 어디에도 없는 상태**다. 조용히 스킵하면 그 사실이 안 보인다.
|
|
1337
1337
|
*
|
|
1338
|
-
* **미지 값**(`"sourse"` 같은 오탈자)도 같은 대접이다 — `none` 이되 **말은 한다**(
|
|
1339
|
-
* 경고 ⑦). 종전에는 이 자리가 침묵이었다: 유효값이 `"source"` 하나로 줄면서 오탈자 한 글자가 N1~N5
|
|
1338
|
+
* **미지 값**(`"sourse"` 같은 오탈자)도 같은 대접이다 — `none` 이되 **말은 한다**(경고 ⑦). 종전에는 이 자리가 침묵이었다: 유효값이 `"source"` 하나로 줄면서 오탈자 한 글자가 N1~N5
|
|
1340
1339
|
* 전체를 조용히 끄는 스위치가 됐고, `content/pages/*.json` 이 **실재해도** 선언이 없는 레포(`inferred`)
|
|
1341
1340
|
* 보다 낮은 감시를 받았다. 계약을 지키려던 손이 오타 때문에 계약 밖으로 떨어지는데 화면에 아무 말이
|
|
1342
1341
|
* 없는 것 — 그것이 조용한 실패다.
|
|
1343
1342
|
*
|
|
1344
|
-
* ⚠ **경고까지다. error 도, 모드 승격도 아니다.** 우리 계약이 아닌 값을 적는 것은 자유이고(
|
|
1345
|
-
* 요건 1 — 어휘 강제 금지), 남의 `zalkera.content` 를 우리가 해석해 검사를 걸면 그것이 강제가 된다.
|
|
1343
|
+
* ⚠ **경고까지다. error 도, 모드 승격도 아니다.** 우리 계약이 아닌 값을 적는 것은 자유이고(어휘 강제 금지), 남의 `zalkera.content` 를 우리가 해석해 검사를 걸면 그것이 강제가 된다.
|
|
1346
1344
|
* 여기서 하는 일은 판정을 바꾸는 것이 아니라 **판정 결과를 보이게 하는 것**뿐이다.
|
|
1347
1345
|
*/
|
|
1348
1346
|
function detectContentMode(srcDir) {
|
|
@@ -1416,7 +1414,7 @@ function contentPageFiles(repoRoot) {
|
|
|
1416
1414
|
const CONTENT_MODE = detectContentMode(root);
|
|
1417
1415
|
|
|
1418
1416
|
/**
|
|
1419
|
-
* D1·D2 — **문서가 가리키는 좌표가
|
|
1417
|
+
* D1·D2 — **문서가 가리키는 좌표가 실물인가**.
|
|
1420
1418
|
*
|
|
1421
1419
|
* codegen 은 레포 루트 `AGENTS.md` 를 **가장 먼저** 읽는다. 그 문서가 없는 파일을 가리키면 에이전트는
|
|
1422
1420
|
* 없는 것을 찾다가 제 좌표를 짜 버린다 — 2026-07-30 토큰 기준선 실측에서 실제로 났다(페이지 신설
|
|
@@ -2086,23 +2084,23 @@ function checkLayoutBlastRadius() {
|
|
|
2086
2084
|
|
|
2087
2085
|
/**
|
|
2088
2086
|
* C2 — 섹션 렌더러 커버리지. vendored `@zalkera/client` 의 SECTION_CONTRACT 와 SectionRenderer 의 case 를
|
|
2089
|
-
* 대조한다. 어휘 사본이 넷이라 사람 주석 규약으로는 갈라짐을 못 막는다는 게 실측된
|
|
2087
|
+
* 대조한다. 어휘 사본이 넷이라 사람 주석 규약으로는 갈라짐을 못 막는다는 게 실측된 교훈이라,
|
|
2090
2088
|
* 레포 안에서 확인 가능한 짝은 기계가 센다. 계약을 못 읽으면(구 client·미설치) **검사를 건너뛴다** —
|
|
2091
2089
|
* BYO 레포에서 이 검사가 빌드를 막으면 안 되기 때문이다.
|
|
2092
2090
|
*/
|
|
2093
2091
|
/**
|
|
2094
|
-
* S8 — 표현 계약(L1) 배선
|
|
2092
|
+
* S8 — 표현 계약(L1) 배선 검사. **declared 전용**이다: S8 은 위생이 아니라 **선언의 이행 검사**라,
|
|
2095
2093
|
* 계약을 자처하지 않은 레포에는 검사할 약속 자체가 없다(inferred·none 스킵 — S1~S5 와 게이팅이 다른 이유).
|
|
2096
2094
|
*
|
|
2097
2095
|
* 두 조각을 센다:
|
|
2098
2096
|
* - **S8-a** globals.css 의 `@theme` + `--color-primary` — 없으면 `bg-primary` 유틸리티 자체가 생성되지 않는다.
|
|
2099
|
-
* 4키 전수·knob 검사는 하지 않는다(정당한 변형에 오탐한다
|
|
2097
|
+
* 4키 전수·knob 검사는 하지 않는다(정당한 변형에 오탐한다).
|
|
2100
2098
|
* - **S8-b** root layout 의 `parseThemeColors(` **호출** + `<html>` 의 `style` — 이 주입이 L1 의 심장이다.
|
|
2101
2099
|
* **문자열이 아니라 호출을 앵커로 삼는다**: layout 주석이 `themeColors` 를 담고 있어(실측) 문자열 검사는
|
|
2102
2100
|
* 배선을 지우고 주석만 남긴 소스를 통과시킨다. import 원천(로컬 `lib/theme`·`@zalkera/client`)은 묻지 않는다.
|
|
2103
2101
|
*
|
|
2104
2102
|
* **한계 정직**: 존재 검사지 동작 검사가 아니다 — 호출하고 결과를 안 쓰거나 빈 객체를 실으면 통과한다.
|
|
2105
|
-
* "배선을 지웠다"는 잡고 "배선이 고장났다"는 못 잡는다. 나머지 반쪽은 등재 전 스모크 개시(
|
|
2103
|
+
* "배선을 지웠다"는 잡고 "배선이 고장났다"는 못 잡는다. 나머지 반쪽은 등재 전 스모크 개시(실효층)다.
|
|
2106
2104
|
*/
|
|
2107
2105
|
function checkThemeWiring() {
|
|
2108
2106
|
if (STYLE_MODE !== "declared") return; // 선언 없는 레포의 주입 부재는 결함이 아니라 정상이다.
|
|
@@ -2139,7 +2137,7 @@ function checkThemeWiring() {
|
|
|
2139
2137
|
const injects = /parseThemeColors\s*\(/.test(src) && /<html[^>]*\sstyle=/.test(src);
|
|
2140
2138
|
if (injects) return;
|
|
2141
2139
|
|
|
2142
|
-
// 탈출구 — 손으로 계약을 지키는 것도 정당하다(
|
|
2140
|
+
// 탈출구 — 손으로 계약을 지키는 것도 정당하다("kit 은 자격 조건이 아니다"). 다른 이름의
|
|
2143
2141
|
// 자기 헬퍼로 배선한 레포를 error 로 막으면 기계가 정당한 자유를 벌한다. 마커면 warning 으로 강등하고
|
|
2144
2142
|
// 실효 확인은 스모크 개시(실효층)가 맡는다. 마커는 원문에서 찾는다(주석이 곧 마커다).
|
|
2145
2143
|
// ⚠ **`//` 앵커와 사유를 요구한다.** 앵커가 없으면 문자열 리터럴 안의 같은 글자가 면제로 먹는다 —
|
|
@@ -2437,7 +2435,7 @@ function checkContentContract() {
|
|
|
2437
2435
|
// 필수 참조를 `requiredRefs`(이제 빈 배열)에서 이 키로 옮겼는데 이 검사기는 옛 키만 읽고 있었다 —
|
|
2438
2436
|
// **참조가 하나도 없는 섹션이 통과했다**(실측). N5 가 존재 이유로 삼는 바로 그 결함이 무검출이었다.
|
|
2439
2437
|
// 팩 게이트(`pack-preset.mjs`)는 anyOf 를 집행하고 있어 **어휘 사본 둘이 갈라진 상태**였다.
|
|
2440
|
-
// ⚠ rev 6 에서 그 두 타입이 어휘에서 삭제돼 **오늘 이 축을 쓰는 타입은 0
|
|
2438
|
+
// ⚠ rev 6 에서 그 두 타입이 어휘에서 삭제돼 **오늘 이 축을 쓰는 타입은 0 이다**.
|
|
2441
2439
|
// 코드를 남기는 것은 계약 기계를 유지하기 위해서다 — 여기를 지우면 참조 필수 타입이 다시
|
|
2442
2440
|
// 생기는 날 같은 무검출이 재발한다(그것이 이 주석이 기록하는 사고다).
|
|
2443
2441
|
for (const group of spec?.requiredRefsAnyOf ?? []) {
|
|
@@ -2524,7 +2522,7 @@ function check(file) {
|
|
|
2524
2522
|
// 문자열까지 지운 사본에서 찾으면 className 안의 값이 소거돼 영원히 못 잡는다).
|
|
2525
2523
|
const text = stripComments(src);
|
|
2526
2524
|
|
|
2527
|
-
// S6: 남의 토큰 어휘(shadcn 기본 변수명). 재작성
|
|
2525
|
+
// S6: 남의 토큰 어휘(shadcn 기본 변수명). 재작성 표의 좌변이 그대로 남아 있으면 잡는다.
|
|
2528
2526
|
// 우리에겐 정의가 없는 토큰이라 Tailwind 가 클래스를 만들지 않고 → 색이 조용히 빠진 채 배포된다.
|
|
2529
2527
|
const s6 = styleSink("S6");
|
|
2530
2528
|
if (s6) {
|
|
@@ -2892,7 +2890,7 @@ function findMutationHandlers(code) {
|
|
|
2892
2890
|
* 철자까지 요구했고, 그 결과 **정상 코드 6형태가 빨개졌다**(중괄호 `if`, 세미콜론 없는 스타일,
|
|
2893
2891
|
* 타입 주석, 긴 주석, `!== null`, 네임스페이스 import — 심의 실측). 거짓 양성은 우회보다 위험하다:
|
|
2894
2892
|
* 검사기가 정상 리팩터링을 막으면 사람이 면제 마커를 남발하거나 `validate` 를 꺼 버린다. 그 둘 다
|
|
2895
|
-
*
|
|
2893
|
+
* "사람이 관리하는 예외 목록"의 부활이다 — 이 검사기가 피하려는 형태가 그것이다.
|
|
2896
2894
|
*/
|
|
2897
2895
|
function judgeGuardPlacement(rawBody) {
|
|
2898
2896
|
let body = rawBody;
|
|
@@ -2941,7 +2939,7 @@ function judgeGuardPlacement(rawBody) {
|
|
|
2941
2939
|
);
|
|
2942
2940
|
}
|
|
2943
2941
|
|
|
2944
|
-
// ── X1: 교차사이트 위조 가드
|
|
2942
|
+
// ── X1: 교차사이트 위조 가드 ─────────────────────────────
|
|
2945
2943
|
//
|
|
2946
2944
|
// 변이 메서드(POST·PUT·PATCH·DELETE)를 export 하는 라우트 핸들러는 **자기 본문의 첫 구문으로**
|
|
2947
2945
|
// `assertSameOrigin` 을 불러야 한다. 경로 목록이 아니라 **메서드**로 판정하는 이유는,
|
|
@@ -2953,7 +2951,7 @@ function judgeGuardPlacement(rawBody) {
|
|
|
2953
2951
|
// ⚠ **초판은 파일 단위 문자열 검사였고, 가드 없는 변이 라우트를 네 형태로 통과시켰다**(심의 실측):
|
|
2954
2952
|
// ⒜ `export const POST = async (req) => …`(화살표라 `function` 정규식에 안 걸림) ⒝ 가드가 `GET`
|
|
2955
2953
|
// 에만 있고 `POST` 는 무방비 ⒞ `assertSameOrigin(req);` 로 **반환값을 버림** ⒟ 가드가 쿠키 쓰기
|
|
2956
|
-
// **뒤**. ⒟ 는 특히
|
|
2954
|
+
// **뒤**. ⒟ 는 특히 실질 불변식을 깬다 — `cookies()` 변이는 뒤에 만든
|
|
2957
2955
|
// `NextResponse` 에 그대로 합류하므로 **403 응답에 `Set-Cookie` 가 실린다**(실측 재현됨).
|
|
2958
2956
|
// 그래서 판정을 파일이 아니라 **핸들러 본문 단위**로 올린다([judgeGuardPlacement]).
|
|
2959
2957
|
//
|
|
@@ -2979,7 +2977,7 @@ function checkCrossOriginGuards() {
|
|
|
2979
2977
|
// ⚠ **초판의 두 번째 좌표 오류 — `app/api` 만 걸었다.** Next 의 route handler 는 `app/` 아래
|
|
2980
2978
|
// 어디에나 살 수 있고 이 레포에도 실물이 있다(`app/media/[id]/route.ts` — 오늘은 GET 전용이라
|
|
2981
2979
|
// 피해 0). `app/upload/route.ts` 같은 자리에 변이 라우트가 생기면 검사기가 **존재 자체를
|
|
2982
|
-
// 모른다**. 경로가 아니라 메서드로 판정한다는
|
|
2980
|
+
// 모른다**. 경로가 아니라 메서드로 판정한다는 원칙과도 전수 수집이 맞다.
|
|
2983
2981
|
collect(join(root, "app"));
|
|
2984
2982
|
|
|
2985
2983
|
const exempted = [];
|
|
@@ -3096,8 +3094,7 @@ function checkCrossOriginGuards() {
|
|
|
3096
3094
|
}
|
|
3097
3095
|
// ⚠ **원문(raw)에 건다.** `stripLiterals` 를 거친 소스에서는 `sameSite: "strict"` 가
|
|
3098
3096
|
// `sameSite: " "` 라 이 검사가 **어떤 파일에서도 매치되지 않는다** — 실제로 그렇게
|
|
3099
|
-
// 죽어
|
|
3100
|
-
// 적어 뒀다. 값 검사는 원문, 코드 구조 검사는 stripped — 이 구분을 지켜라.
|
|
3097
|
+
// 죽어 있었다(재현: `strict` 로 바꿔도 통과). 값 검사는 원문, 코드 구조 검사는 stripped — 이 구분을 지켜라.
|
|
3101
3098
|
if (/sameSite:\s*["']strict["']/i.test(consumeDef.raw)) {
|
|
3102
3099
|
crossOriginSink().push(
|
|
3103
3100
|
`[X3] ${relative(root, consumeDef.file)} 가 쿠키를 sameSite: "strict" 로 답니다 —` +
|
|
@@ -3378,7 +3375,7 @@ if (styleDeclarationNote !== null) {
|
|
|
3378
3375
|
);
|
|
3379
3376
|
}
|
|
3380
3377
|
|
|
3381
|
-
checkCrossOriginGuards(); // X1·X2·X3 — 교차사이트 위조
|
|
3378
|
+
checkCrossOriginGuards(); // X1·X2·X3 — 교차사이트 위조 가드.
|
|
3382
3379
|
checkPagesRouter(); // C1p·C1pa·X1p — Pages Router 좌표(App Router 전용이던 사각).
|
|
3383
3380
|
checkEnvFiles(); // E3 — `.env*` 의 NEXT_PUBLIC_ 시크릿.
|
|
3384
3381
|
checkServingOutputContract(); // O1 — 서빙 산출물 계약(경고 전용·관문 승격 영구 금지).
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "AEO 보장 표면 계약
|
|
2
|
+
"$comment": "AEO 보장 표면 계약 정본. 형제 계약 section-vocabulary.json 과 같은 거처·같은 관례(contractRev·기계 대조·사람 주석은 이식에서 진다)를 따른다. 어휘가 '레이아웃 규격'이 아니라 '유형별 최소 AEO/SEO 보장 계약'이라는 재정의(오너 정정 B)의 기계 형태다. 구조화 데이터에는 생김새가 없어 디자인 자유를 하나도 깎지 않고 바닥만 깔 수 있다 — 이 파일이 그 바닥이다.",
|
|
3
3
|
"contractRev": 1,
|
|
4
4
|
|
|
5
5
|
"conventions": {
|
|
6
|
-
"canonicalHere": "이 파일이 정본이다. zalkera-client/llms.txt §5.1 은 같은 계약의 **사람/AI용 운반체**이고, 대조는
|
|
6
|
+
"canonicalHere": "이 파일이 정본이다. zalkera-client/llms.txt §5.1 은 같은 계약의 **사람/AI용 운반체**이고, 대조는 동기 검사기가 기계로 센다. 채널이 늘어도 사본을 늘리지 않는다 — AGENTS.md·콘솔 문서·MCP 는 참조 링크만 든다.",
|
|
7
7
|
"floorNotCeiling": "보장은 바닥이지 천장이 아니다. 이 표를 못 채우는 템플릿도 만들 수 있고 적재될 수 있다 — 못 거는 것은 **그 카테고리 간판**뿐이고, 그것도 결함 판정이 아니라 진열 축의 사실 판정이다(§4.1·오너 정정 A).",
|
|
8
8
|
"whereTheWeightIs": "보장의 무게는 **라우트**에 있다. 현행 보장(Product·Organization·BlogPosting·BreadcrumbList)은 라우트·렌더러의 속성이고, 섹션이 직접 산출하는 것은 FAQPage 하나다. **섹션 어휘를 0개 쓰는 사이트도 라우트 보장은 똑같이 받는다**(§1.0-a).",
|
|
9
|
-
"verdictFromArtifact": "판정 지점은 소스가 아니라 **산출물**이다(오너 정정 C). '이 섹션을 써라'는 자유를 깎지만 '개시된 페이지에서 이 그래프가 나오는가'는 아무것도 깎지 않는다. 검사기는
|
|
9
|
+
"verdictFromArtifact": "판정 지점은 소스가 아니라 **산출물**이다(오너 정정 C). '이 섹션을 써라'는 자유를 깎지만 '개시된 페이지에서 이 그래프가 나오는가'는 아무것도 깎지 않는다. 검사기는 @zalkera/client 의 `zalkera-check-aeo` 이고, 실행 자리는 promote 전 스모크 개시다. **측정 재료의 출처**: 목록형 표면의 원소는 업무 데이터에서 온다 — 팩 시드는 상품·갈래를 만들지 않으므로, 시험 테넌트에 파트너 API 로 적재한 뒤 크롤한다. 빈 테넌트에서 나온 원소 0개짜리 통과는 형상만 본 가짜 PASS 다(COMMERCE unblockedNote 의 실측이 그 사례다).",
|
|
10
10
|
"requiredVsPlanned": "`required` 는 **이 rev 가 지금 강제하는 것**이고, `planned` 는 같은 카테고리의 목표 표면 중 **아직 우리 본보기도 못 내는 것**이다. planned 는 게이트가 아니라 T6 작업 지시서이고, 검사기는 이를 실패가 아니라 PLANNED_MISSING 으로 보고한다. `realized` 는 **그 지시가 실제로 이행됐는가**를 적는 별개 축이다 — 이행됐다고 자동으로 required 가 되지는 않는다. required 로 올리는 것은 이 표의 rev 상향이고, 그 시점은 **이행분이 개시된 산출물에 실제로 나타나는 발행 단위**다 — 코드 병합이 아니다(판정 지점이 산출물이므로 잣대의 상향 시점도 산출물이어야 한다. 병합만으로 표를 올리면 아직 재빌드 안 된 팩·구 SDK 로 빌드된 팩이 통과 못 할 요구를 받는다). rev 2 가 그 실례다: `openingHours` 는 저장 자리(백엔드 컬럼)만 섰고 `@zalkera/client` 0.9.0 미발행·템플릿 소비 0 이라, rev 2 는 T6 병합이 아니라 **client 발행 + 본보기 팩 재빌드**가 끝난 단위에서만 설 수 있다. 이 분리가 없으면 둘 중 하나를 해야 한다 — 규범을 낮추거나(보장이 거짓말이 된다), 오늘 아무도 못 거는 표를 강제하거나(카탈로그가 빈 선반이 된다).",
|
|
11
11
|
"growth": "표는 자란다. rev 상향 시 **기존 등재물의 소급 탈락은 금지**다 — 구 rev 판정은 판정 당시 rev 를 병기한 배지로 유효하게 지속되고, 신 rev 요구는 신규 등재·재promote 부터 건다. 순수 라벨 카테고리가 나중에 이 표에 편입되면 기존 등재물은 유예한다(§1.0-c). 조용한 소급은 이 설계가 죽이려던 조용한 실패의 재생산이다.",
|
|
12
12
|
"publication": "`published` 는 그 표면의 규범이 **공개 문서(llms.txt §5.1)에 이미 나가 있는가**다. false = 내부 정본에만 있다 = 아직 공표 전. 내부 정본이 앞서 있는 것은 무해하다(작업 지시서다). 그러나 **공표가 본보기를 앞지르면 안 된다** — '레시피가 본보기를 앞지르지 않는다'(§1.5-a). false→true 뒤집기는 T6 그린과 같은 발행 단위 이후이고(T4b), 대조 스크립트가 양방향으로 센다: published 인데 §5.1 에 없으면 실패, published 가 아닌데 §5.1 에 이미 있으면 **역방향 실패**(공표가 표를 앞질렀다).",
|
|
13
|
-
"categoryStorage": "카테고리는 `theme.categories` 자유 문자열 **배열**이다 — enum 으로 못박지
|
|
13
|
+
"categoryStorage": "카테고리는 `theme.categories` 자유 문자열 **배열**이다 — enum 으로 못박지 않는다. **여기 항목이 있는 이름만** 게이트가 걸리고, 없는 이름은 순수 라벨이다(예: '미니멀'). 한 팩이 보장 이름을 여럿 걸 수 있고, 그때는 **주장한 이름 전부**가 스냅샷에서 개별 PASS 여야 공개된다(AND) — 절반만 지킨 약속은 거짓이라, 부분 통과를 표기로 흡수하지 않고 promote 를 막는다. 부분만 걸고 싶으면 주장을 줄이면 된다. 값 이름을 업종어가 아니라 유형어로 두는 이유는 형제 계약의 명명 규약과 같다: 업종어를 박으면 재사용이 죽는다(DOCTOR_INTRO 교훈). 그래서 예약 칸은 `BEAUTY` 가 아니라 `BOOKING` 이다 — 예약을 파는 것은 뷰티만이 아니다."
|
|
14
14
|
},
|
|
15
15
|
|
|
16
16
|
"routes": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"cmsPage": {"kind": "INSTANCE", "pattern": "/{slug}", "note": "콘솔·시드가 만든 고정 페이지(src/app/[slug]/page.tsx). 예약 세그먼트(products·blog·c·policies·contact 등)는 인스턴스 후보에서 뺀다."},
|
|
21
21
|
"productList": {"kind": "LITERAL", "path": "/products", "note": "**T6-ⓑ 가 신설했다**(src/app/products/page.tsx · ItemList+BreadcrumbList · sitemap 등재). 레시피는 그 전부터 있었다 — llms.txt §4.1 이 이 라우트를 그린다. 개시된 사이트에 나타나는 것은 템플릿 병합 뒤 **그 팩을 다시 빌드·개시한 다음**이므로, 낡은 산출물을 크롤하면 여전히 부재로 잡힌다 — 그건 라우트 부재가 아니라 산출물이 낡은 것이다."},
|
|
22
22
|
"productDetail": {"kind": "INSTANCE", "pattern": "/products/{slug}"},
|
|
23
|
-
"productCategory": {"kind": "INSTANCE", "pattern": "/c/{slug}", "note": "
|
|
23
|
+
"productCategory": {"kind": "INSTANCE", "pattern": "/c/{slug}", "note": "시작 소스가 `src/app/c/[slug]/page.tsx` 로 낸다(CollectionPage(mainEntity=ItemList) + BreadcrumbList · sitemap 에 전 카테고리 등재 · `/products` 에 카테고리 내비). 경로는 발명하지 않았다 — llms.txt §4.1 레시피가 카테고리 링크를 `/c/{slug}` 로 이미 그리고 있었고 §4.1-a 가 그 실물의 레시피다. 막고 있던 것이 라우트가 아니라 데이터층이었다는 2026-07-28 실측도 같은 트랜치가 해소했다(`product_category_map` 쓰기 경로 신설). **개시된 사이트에 나타나는 것은 `@zalkera/client` 발행 + 그 팩 재빌드 다음**이므로 낡은 산출물을 크롤하면 여전히 부재로 잡힌다 — 그건 라우트 부재가 아니라 산출물이 낡은 것이다."},
|
|
24
24
|
"blogList": {"kind": "LITERAL", "path": "/blog"},
|
|
25
25
|
"blogDetail": {"kind": "INSTANCE", "pattern": "/blog/{slug}"}
|
|
26
26
|
},
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"forbiddenTypes": [
|
|
68
68
|
{
|
|
69
69
|
"type": "Review",
|
|
70
|
-
"why": "자사 사이트의 자사 후기에 별점을 붙이는 것은 self-serving reviews 정책 위반이라 제재
|
|
70
|
+
"why": "자사 사이트의 자사 후기에 별점을 붙이는 것은 self-serving reviews 정책 위반이라 제재 대상이다. TESTIMONIALS 섹션이 jsonLd 를 안 내는 이유가 이것이다 — 어휘 12종 중 jsonLd 비-null 이 FAQ_LIST 하나뿐인 것은 미완성이 아니라 판단이다."
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"type": "AggregateRating",
|
|
@@ -131,10 +131,10 @@
|
|
|
131
131
|
"shipNote": "**축소 보장표로 등재된 상태다**(§4.2). ItemList·openingHours 를 지금 required 로 올리면 오늘 아무 팩도 이 칸을 못 걸고, 낮춰 적으면 보장이 거짓이 된다. 그래서 지금 참인 것만 강제하고 나머지는 planned 로 rev 관리한다 — rev 2 에서 required 로 올라간다. **그 rev 2 는 T6 병합 시점이 아니다**: openingHours 는 저장 자리만 서 있고 `@zalkera/client` 0.9.0(타입 추가)이 미발행이라 템플릿이 그 값을 소비하지 못해 그래프에 아무것도 안 나간다. rev 2 가 서는 단위는 **client 발행 + 본보기 팩 재빌드**다(conventions.requiredVsPlanned).",
|
|
132
132
|
"required": [
|
|
133
133
|
{"id": "home-localbusiness", "route": "home", "jsonLd": ["LocalBusiness", "BeautySalon", "HealthAndBeautyBusiness"], "ssr": true, "published": false, "publishTranche": "T4b", "why": "예약을 파는 곳은 **물리 점포**다. Organization 으로 두면 '어디로 가면 되는가'가 기계에 안 읽힌다. organizationJsonLd 는 config.businessType==BEAUTY 일 때 BeautySalon 으로 좁힌다 — 즉 이 요구는 렌더러가 아니라 **테넌트 설정**이 만족시킨다."},
|
|
134
|
-
{"id": "service-detail-product", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "시술 한 건이 상품 한 행이다. 가격이 Offer 로 나가야 '얼마인가'가 답변 엔진에 읽힌다. **이 표면을 재려면 카탈로그에 상품이 있어야 한다** —
|
|
134
|
+
{"id": "service-detail-product", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "시술 한 건이 상품 한 행이다. 가격이 Offer 로 나가야 '얼마인가'가 답변 엔진에 읽힌다. **이 표면을 재려면 카탈로그에 상품이 있어야 한다** — 시드는 상품을 만들지 않는다. 전제가 사라진 것이 아니라 **거처가 다르다**: 측정은 시험 테넌트에 파트너 API(콘솔과 같은 엔드포인트)로 상품을 적재한 뒤 크롤한다. 빈 테넌트에서 잰 값은 재료가 없는 것이지 통과가 아니다."}
|
|
135
135
|
],
|
|
136
136
|
"planned": [
|
|
137
|
-
{"id": "service-menu-itemlist", "route": "any", "jsonLd": ["ItemList"], "tranche": "T6-ⓒ", "realized": "DONE", "realizedNote": "**운반체가 바뀌었다**(
|
|
137
|
+
{"id": "service-menu-itemlist", "route": "any", "jsonLd": ["ItemList"], "tranche": "T6-ⓒ", "realized": "DONE", "realizedNote": "**운반체가 바뀌었다**(contractRev 6). 종전 운반체이던 `SERVICE_MENU` 섹션은 어휘에서 삭제됐다 — 진열은 선언이 아니라 소스가 `@zalkera/client` 를 직접 호출해 그리는 일이기 때문이다. 그래서 이 ItemList 를 내는 자리는 **`/products` 목록 라우트**(전 팩 공유·직접 호출)와 소스가 홈에 조합하는 진열 레일이다. 산출 자체는 이미 돈다(라우트가 낸다). 여기서 required 로 올리는 것은 이 표의 **rev 2** 이고, 그 단위는 **본보기 팩을 다시 빌드해 개시하고 시험 테넌트에 상품을 적재해 잰 뒤**다 — 같은 rev 의 openingHours 는 거기에 더해 client 발행까지 필요하다. id 를 그대로 두는 것은 append-only 로그의 추적성 때문이다(운반체가 바뀌었지 약속이 바뀐 것이 아니다).", "why": "시술 목록의 정위치를 홈의 한 섹션 타입으로 못박았던 것이 rev 1 의 판단이었다지금은 그 판단을 뒤집었다 — 목록을 **어디에 어떻게** 그릴지는 어휘가 아니라 소스의 몫이고, 어휘에 고정하면 그 진열의 디자인이 얼어붙어 해자(자연어로 다양한 디자인)와 반대로 간다. 그래서 `route` 는 여전히 `any` 다: 홈이든 `/products` 든 **개시된 페이지 어딘가에서 ItemList 가 나오는가**만 잰다(판정 지점은 소스가 아니라 산출물 — `conventions.verdictFromArtifact`)."},
|
|
138
138
|
{"id": "opening-hours", "route": "home", "jsonLdProperty": "openingHours", "tranche": "T6-ⓓ", "realized": "PARTIAL", "realizedNote": "**절반이다.** 저장 자리는 섰다(백엔드 site_config 확장 + 전체교체 경로 4곳). 템플릿 렌더는 @zalkera/client 0.9.0 발행 뒤라 아직 못 나간다(발행은 2FA 라 오너 몫). 저장 자리만으로는 그래프가 안 나가므로 이 항목이 여전히 red 로 잡히는 것이 맞다.", "why": "**전 레포에 저장할 필드 자체가 없다** — 렌더러 문제가 아니라 스키마 문제다. 거처는 예약 척추 위저드의 영업시간 입력(§2.4)이고, site_config 확장 마이그레이션이 선행한다."}
|
|
139
139
|
],
|
|
140
140
|
"why": "§4.2 예약 칸. 상세는 지금 나가고, 목록(ItemList)·영업시간은 T6 이후다."
|
|
@@ -143,13 +143,13 @@
|
|
|
143
143
|
"category": "COMMERCE",
|
|
144
144
|
"label": "쇼핑몰",
|
|
145
145
|
"shipState": "SHIPPABLE",
|
|
146
|
-
"unblockedNote": "**2026-08-01 해제.** `unblockGate` 가 요구한 것은 결정이 아니라 **측정**이었다 — \"이 검사기가 우리 팩에서 실제로 그린인가\"(판정 지점이 소스가 아니라 산출물이라서). 그 측정이 끝났다: `zalkera-storefront-examples` 의 커머스 예시(`shop-goods`)를 팩→적재→개시→서빙하고 `check-aeo-surfaces` 를 걸어 **COMMERCE required 4/4 + siteWide 4 + negative 2 = 10/10 PASS**. ⚠ 첫 측정은 매핑 상품이 0건인 빈 카테고리를 크롤해 `CollectionPage.mainEntity.itemListElement` 가 **원소 0개인 채로 통과**했다 — 형상만 보는 가짜 PASS 였다. 상품 5종을 매핑해 원소가 찬 상태에서 다시 잰 결과를 근거로 삼는다. 선행 조건이던 데이터층·client `categoryId`·`/c/{slug}` 는 2026-07-29 요건2 트랜치에서 이미 섰고, 남아 있던 것은 이 측정
|
|
146
|
+
"unblockedNote": "**2026-08-01 해제.** `unblockGate` 가 요구한 것은 결정이 아니라 **측정**이었다 — \"이 검사기가 우리 팩에서 실제로 그린인가\"(판정 지점이 소스가 아니라 산출물이라서). 그 측정이 끝났다: `zalkera-storefront-examples` 의 커머스 예시(`shop-goods`)를 팩→적재→개시→서빙하고 `check-aeo-surfaces` 를 걸어 **COMMERCE required 4/4 + siteWide 4 + negative 2 = 10/10 PASS**. ⚠ 첫 측정은 매핑 상품이 0건인 빈 카테고리를 크롤해 `CollectionPage.mainEntity.itemListElement` 가 **원소 0개인 채로 통과**했다 — 형상만 보는 가짜 PASS 였다. 상품 5종을 매핑해 원소가 찬 상태에서 다시 잰 결과를 근거로 삼는다. 선행 조건이던 데이터층·client `categoryId`·`/c/{slug}` 는 2026-07-29 요건2 트랜치에서 이미 섰고, 남아 있던 것은 이 측정 하나였다.",
|
|
147
147
|
"required": [
|
|
148
148
|
{"id": "product-detail", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "오늘 유일하게 나가는 커머스 표면. variant 마다 Offer 를 낸다(항상 variant 단위 판매)."},
|
|
149
149
|
{"id": "product-detail-breadcrumb", "route": "productDetail", "jsonLd": ["BreadcrumbList"], "ssr": true, "published": true, "llmsMarker": "목록·상세엔 `BreadcrumbList`", "why": "검색결과에 '홈 > 상품 > 이름' 경로가 선다."},
|
|
150
150
|
{"id": "product-list-itemlist", "route": "productList", "jsonLd": ["ItemList"], "ssr": true, "published": true,
|
|
151
151
|
"llmsMarker": "목록 라우트도 그래프를 낸다 — `ItemList`", "publishTranche": "T4b", "realized": "DONE", "realizedNote": "T6 이 /products 목록 라우트를 신설했다(404→200 · ItemList+BreadcrumbList · sitemap 등재). 이 항목은 이제 통과 가능하다 — 그래도 COMMERCE 칸이 안 열리는 이유는 같은 칸의 CollectionPage 다.", "why": "**라우트 자체가 없다.** 목록이 없으면 '이 가게가 무엇을 파는가'를 기계가 한 번에 못 받는다 — 상세 N건을 개별로 발견해야 한다. 쇼핑몰 3표면 중 2개가 비는 이유가 여기서 시작한다."},
|
|
152
|
-
{"id": "product-category-collectionpage", "route": "productCategory", "jsonLd": ["CollectionPage"], "requiredChildren": ["ItemList"], "ssr": true, "published": true, "llmsMarker": "카테고리 목록 = `CollectionPage`(`mainEntity` 로 `ItemList`)", "realized": "DONE", "realizedNote": "**2026-08-01 공표·해제.** 아래 서술이 남겨 둔 상향 단위(client 발행 + 본보기 팩 재빌드)가 둘 다 충족됐다 — client 는 `categoryId` 를 실은 채 발행돼 있고(설치본 0.13.1·npm 0.14.0), 커머스 예시 팩을 재빌드·개시·서빙해 `check-aeo-surfaces` 로 **원소가 찬 CollectionPage** 를
|
|
152
|
+
{"id": "product-category-collectionpage", "route": "productCategory", "jsonLd": ["CollectionPage"], "requiredChildren": ["ItemList"], "ssr": true, "published": true, "llmsMarker": "카테고리 목록 = `CollectionPage`(`mainEntity` 로 `ItemList`)", "realized": "DONE", "realizedNote": "**2026-08-01 공표·해제.** 아래 서술이 남겨 둔 상향 단위(client 발행 + 본보기 팩 재빌드)가 둘 다 충족됐다 — client 는 `categoryId` 를 실은 채 발행돼 있고(설치본 0.13.1·npm 0.14.0), 커머스 예시 팩을 재빌드·개시·서빙해 `check-aeo-surfaces` 로 **원소가 찬 CollectionPage** 를 실측했다. 종전 서술: 2026-07-29 요건2 트랜치가 네 층을 다 세웠다: ⑴ 데이터층 — `product_category_map` 쓰기 경로(`PUT /api/partner/products/{id}/categories`, 집합 치환. 없는 카테고리와 **남의 테넌트 카테고리가 같은 404**) ⑵ 공개 API — 목록의 `categoryId` 존재 서브쿼리 필터 ⑶ 콘솔 — 상품 상세 패널의 카테고리 저장 ⑷ 템플릿 — `/c/[slug]`(CollectionPage 가 ItemList 를 mainEntity 로 품는다)+BreadcrumbList+sitemap. **그래도 required 로 올리지 않는다**: 소비 축이 `@zalkera/client` 의 `categoryId` 파라미터인데 그 버전이 미발행이라, 지금 required 로 올리면 낡은 SDK 로 빌드된 팩이 못 낼 표면을 요구하게 된다. 상향 단위는 client 발행 + 본보기 팩 재빌드다.", "why": "오너가 든 세 표면(카테고리·상품 상세·상품 목록) 중 마지막 하나이고, **셋 중 유일하게 아직 안 선다**. 막는 것이 라우트가 아니라는 것이 실측의 요점이다(2026-07-28): `product_category_map` 은 V9 에 테이블로 존재하지만 **INSERT 경로가 전 레포 0건**이고(유일한 코드 접촉은 카테고리 삭제 가드의 COUNT 한 줄), 도메인 모델·공개 API 필터·SDK 파라미터가 전부 없다(`PublicProductSearchRequest` = productType·keyword 뿐이고 그 KDoc 이 매핑이 채워진 뒤 확장한다고 자인한다). 즉 지금 라우트를 지으면 **어느 카테고리를 열어도 상품 0건**이 나온다 — 그건 표면을 만든 것이 아니라 빈 선반을 하나 더 세운 것이다(§4.2 빈 선반 금지)."}
|
|
153
153
|
],
|
|
154
154
|
"why": "오너가 '쇼핑몰이라면 최소한 카테고리·상품 상세·상품 목록은 보장받아야 할 것 아니냐'고 든 그 세 표면이 이 칸의 정의다. 셋 중 하나만 나가고 있다."
|
|
155
155
|
},
|
package/lib/site-crawl.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 개시된 사이트를 훑는 최소 크롤러 — `snapshot-preview.mjs
|
|
3
|
-
* `check-aeo-surfaces.mjs
|
|
2
|
+
* 개시된 사이트를 훑는 최소 크롤러 — `snapshot-preview.mjs`와
|
|
3
|
+
* `check-aeo-surfaces.mjs`가 **같은 사본**을 쓴다.
|
|
4
4
|
*
|
|
5
|
-
* 원래 이 코드는 snapshot-preview 안에 살았다. 보장 검사기가 "산출물을 크롤해 판정한다"
|
|
6
|
-
* 전제 C)를 구현하려면 같은 훑기가 필요한데, 복사하면 두 크롤러가 조용히 갈라진다 — 한쪽이 라우트 분류를
|
|
5
|
+
* 원래 이 코드는 snapshot-preview 안에 살았다. 보장 검사기가 "산출물을 크롤해 판정한다"를 구현하려면 같은 훑기가 필요한데, 복사하면 두 크롤러가 조용히 갈라진다 — 한쪽이 라우트 분류를
|
|
7
6
|
* 고치고 다른 쪽이 안 고치면 "미리보기에는 나오는데 검사기는 못 보는 페이지"가 생긴다. 그래서 옮겼다.
|
|
8
7
|
*
|
|
9
8
|
* ── 외부 의존 0 ────────────────────────────────────────────────────────────
|