@gaonjs/cli 0.35.0 → 0.37.1

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.
Files changed (38) hide show
  1. package/dist/commands/check.js +15 -9
  2. package/dist/commands/dev.js +10 -1
  3. package/dist/commands/gen.d.ts +2 -0
  4. package/dist/commands/gen.js +6 -2
  5. package/dist/commands/new.d.ts +7 -1
  6. package/dist/commands/new.js +15 -2
  7. package/dist/commands/test.d.ts +2 -1
  8. package/dist/commands/test.js +11 -5
  9. package/dist/dev.d.ts +13 -1
  10. package/dist/dev.js +21 -1
  11. package/dist/doctor/no-import-meta-env.d.ts +5 -0
  12. package/dist/doctor/no-import-meta-env.js +98 -0
  13. package/dist/doctor/types.d.ts +1 -1
  14. package/dist/doctor.d.ts +9 -0
  15. package/dist/doctor.js +12 -3
  16. package/dist/env-gen.d.ts +12 -0
  17. package/dist/env-gen.js +63 -0
  18. package/dist/index.d.ts +17 -0
  19. package/dist/index.js +54 -23
  20. package/dist/pm.d.ts +13 -0
  21. package/dist/pm.js +59 -0
  22. package/dist/templates/index.d.ts +17 -0
  23. package/dist/templates/index.js +18 -1
  24. package/dist/templates/index.ts +24 -0
  25. package/dist/templates/project/.env.example.tpl +6 -0
  26. package/dist/templates/project/AGENTS.md.tpl +3 -2
  27. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  28. package/dist/templates/project/agents/async.md.tpl +36 -3
  29. package/dist/templates/project/agents/frontend.md.tpl +52 -1
  30. package/dist/templates/project/agents/i18n.md.tpl +26 -0
  31. package/dist/templates/project/agents/mail.md.tpl +31 -0
  32. package/dist/templates/project/agents/realtime.md.tpl +10 -0
  33. package/dist/templates/project/agents/testing.md.tpl +8 -4
  34. package/dist/templates/project/agents/web.md.tpl +36 -0
  35. package/dist/templates/project/package.json.tpl +1 -1
  36. package/package.json +9 -9
  37. package/dist/templates/auth/app.ts.tpl +0 -29
  38. package/dist/templates/auth/server.ts.tpl +0 -14
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ import { runServeCommand } from "./serve.js";
26
26
  import { runWorkCommand } from "./work.js";
27
27
  import { runJobsCommand } from "./jobs.js";
28
28
  import { runDbCommand } from "./commands/db.js";
29
- import { runDoctorCommand } from "./doctor.js";
29
+ import { runDoctorCommand, ALL_RULES } from "./doctor.js";
30
30
  import { runMcpCommand } from "./commands/mcp.js";
31
31
  export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
32
32
  export { runDevCommand } from "./commands/dev.js";
@@ -95,7 +95,7 @@ function renderHelp(version = VERSION) {
95
95
  "",
96
96
  " 사용법:",
97
97
  " gaon 로드맵과 개발 상태를 출력",
98
- " gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --pm <이름>)",
98
+ " gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --package-manager <pnpm|npm|yarn>)",
99
99
  " gaon dev 개발 스택 통합 (Docker · .gaon · serve · tsc/vue-tsc · 재시작 워처)",
100
100
  " gaon dev --stop-docker Ctrl+C 시 Docker Compose 도 down",
101
101
  " gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker 개별 debug 옵션",
@@ -146,18 +146,10 @@ function renderHelp(version = VERSION) {
146
146
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
147
147
  */
148
148
  export function parseDoctorChecks(argv) {
149
- const known = [
150
- "response-mixing",
151
- "n-plus-one",
152
- "dependency-direction",
153
- "connections",
154
- "migration-diff",
155
- "shared-composable-purity",
156
- "no-auto-import",
157
- "csrf-wiring",
158
- "async-offload",
159
- ];
160
- const isKnown = (s) => known.includes(s);
149
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 26종)를 단일 출처로 쓴다 — 과거
150
+ // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
151
+ // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
152
+ const isKnown = (s) => ALL_RULES.includes(s);
161
153
  const out = [];
162
154
  for (const a of argv) {
163
155
  if (a.startsWith("--check=")) {
@@ -169,6 +161,41 @@ export function parseDoctorChecks(argv) {
169
161
  }
170
162
  return out.length ? out : undefined;
171
163
  }
164
+ /**
165
+ * `gaon new <name>` 의 인자를 파싱한다.
166
+ * 패키지 매니저 플래그: `--package-manager <pm>`(정본) · `--pm <pm>`(별칭) ·
167
+ * 둘 다 `=` 형(`--package-manager=npm`)도 허용. 값을 취하는 플래그이므로
168
+ * 이름 위치 인자를 고를 때 그 값을 건너뛴다 — `gaon new --pm npm demo` 의
169
+ * npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
170
+ */
171
+ export function parseNewArgs(rest) {
172
+ let name;
173
+ let pmRaw;
174
+ for (let i = 0; i < rest.length; i++) {
175
+ const a = rest[i];
176
+ if (a === "--package-manager" || a === "--pm") {
177
+ pmRaw = rest[++i];
178
+ continue;
179
+ }
180
+ const eqPrefix = a.startsWith("--package-manager=")
181
+ ? "--package-manager="
182
+ : a.startsWith("--pm=")
183
+ ? "--pm="
184
+ : undefined;
185
+ if (eqPrefix !== undefined) {
186
+ pmRaw = a.slice(eqPrefix.length);
187
+ continue;
188
+ }
189
+ if (a.startsWith("--"))
190
+ continue;
191
+ if (name === undefined)
192
+ name = a;
193
+ }
194
+ if (pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn") {
195
+ return { name, packageManager: pmRaw };
196
+ }
197
+ return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
198
+ }
172
199
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
173
200
  export function runCli(argv, opts = {}) {
174
201
  const version = opts.version ?? VERSION;
@@ -460,19 +487,23 @@ export function runCli(argv, opts = {}) {
460
487
  // `gaon new <name>` — 프로젝트 스캐폴드(M9-F). 파일 생성 → 의존성 설치 → git init.
461
488
  if (argv[0] === "new") {
462
489
  const rest = argv.slice(1);
463
- let name;
464
- for (const a of rest) {
465
- if (!a.startsWith("--") && name === undefined)
466
- name = a;
467
- }
468
- if (!name) {
490
+ const parsed = parseNewArgs(rest);
491
+ if (!parsed.name) {
469
492
  process.stderr.write(" ✗ gaon new: 프로젝트 이름이 없습니다.\n → 예: gaon new demo\n");
470
493
  process.exitCode = 1;
471
494
  return;
472
495
  }
473
- const pmIdx = rest.indexOf("--pm");
474
- const pmRaw = pmIdx >= 0 ? rest[pmIdx + 1] : undefined;
475
- const packageManager = pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn" ? pmRaw : undefined;
496
+ // 없는 pm 값은 조용히 pnpm 으로 되돌리지 않고 사용자에게 알린다
497
+ // (에러가 수리 안내서 · §7.5.3). 이전엔 무시돼 --package-manager npm
498
+ // pnpm 으로 설치되던 O-1 표류를 근본 차단한다(결정 167).
499
+ if (parsed.unknownPm !== undefined) {
500
+ process.stderr.write(` ✗ gaon new: 알 수 없는 패키지 매니저 '${parsed.unknownPm}'.\n` +
501
+ ` → --package-manager 는 pnpm · npm · yarn 만 지원합니다(기본 pnpm).\n`);
502
+ process.exitCode = 1;
503
+ return;
504
+ }
505
+ const name = parsed.name;
506
+ const packageManager = parsed.packageManager;
476
507
  void runNewCommand(name, {
477
508
  json: argv.includes("--json"),
478
509
  skipInstall: argv.includes("--skip-install"),
package/dist/pm.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type PackageManager = 'pnpm' | 'npm' | 'yarn';
2
+ /**
3
+ * 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는 package.json 의
4
+ * `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을 기록하므로 그것을
5
+ * 최우선으로 삼고, 없으면 락파일, 그래도 없으면 pnpm(골든 경로 기본).
6
+ */
7
+ export declare function detectPackageManager(cwd: string): PackageManager;
8
+ /**
9
+ * `<pm> run <script>` 실행 인자를 pm 별 passthrough 관례에 맞춰 만든다. 잔여
10
+ * 인자가 있을 때 pnpm·npm 은 `--` 로 스크립트에 분리 전달해야 하고(그래야 vitest
11
+ * 필터 등이 도달), yarn(classic)은 `--` 없이 직접 전달한다(결정 170 W1).
12
+ */
13
+ export declare function scriptRunArgs(pm: PackageManager, script: string, extras?: readonly string[]): string[];
package/dist/pm.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @gaonjs/cli · 패키지 매니저 해상 — pm-awareness 단일 소스 (결정 170)
3
+ *
4
+ * CLI 는 대부분 도구를 직접 spawn 한다(vitest·vite·tsc·vue-tsc·node·docker =
5
+ * pm 무관). pm 이 관여하는 접점은 **딱 셋** 뿐이다:
6
+ * 1) `gaon new` 초기 install (`new.ts` · `--package-manager` 선택)
7
+ * 2) `gaon check` build 등 user script (`commands/check.ts`)
8
+ * 3) `gaon test` test user script (`commands/test.ts`)
9
+ *
10
+ * (2)·(3) 은 사용자 스크립트를 **프로젝트가 선언한 pm** 으로 돌려야 한다 —
11
+ * pnpm 하드코딩은 npm/yarn 로 스캐폴드한 프로젝트에서 pnpm 이 "This project is
12
+ * configured to use npm" 으로 실행을 거부해 깨진다. 이 해상 로직을 한 모듈에
13
+ * 모아 check·test 가 **같은 함수**를 쓴다(중복 하드코딩 재발 차단 · 결정 170 W2).
14
+ *
15
+ * anti-creep(결정 170 W3): 새 CLI 명령은 도구를 직접 부른다(pm 무관). 부득이
16
+ * pm 이 필요하면 4번째 하드코딩을 만들지 말고 **반드시 이 모듈을 경유**한다.
17
+ * pnpm = 골든/보장 경로 · npm/yarn = best-effort 탈출구(§6 · CLAUDE.md).
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ /**
22
+ * 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는 package.json 의
23
+ * `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을 기록하므로 그것을
24
+ * 최우선으로 삼고, 없으면 락파일, 그래도 없으면 pnpm(골든 경로 기본).
25
+ */
26
+ export function detectPackageManager(cwd) {
27
+ const pkgPath = join(cwd, 'package.json');
28
+ if (existsSync(pkgPath)) {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
31
+ const pm = pkg.packageManager?.split('@')[0];
32
+ if (pm === 'pnpm' || pm === 'npm' || pm === 'yarn')
33
+ return pm;
34
+ }
35
+ catch {
36
+ // 파싱 실패는 락파일/기본으로 폴백
37
+ }
38
+ }
39
+ if (existsSync(join(cwd, 'pnpm-lock.yaml')))
40
+ return 'pnpm';
41
+ if (existsSync(join(cwd, 'yarn.lock')))
42
+ return 'yarn';
43
+ if (existsSync(join(cwd, 'package-lock.json')))
44
+ return 'npm';
45
+ return 'pnpm';
46
+ }
47
+ /**
48
+ * `<pm> run <script>` 실행 인자를 pm 별 passthrough 관례에 맞춰 만든다. 잔여
49
+ * 인자가 있을 때 pnpm·npm 은 `--` 로 스크립트에 분리 전달해야 하고(그래야 vitest
50
+ * 필터 등이 도달), yarn(classic)은 `--` 없이 직접 전달한다(결정 170 W1).
51
+ */
52
+ export function scriptRunArgs(pm, script, extras = []) {
53
+ const base = ['run', script];
54
+ if (extras.length === 0)
55
+ return base;
56
+ if (pm === 'yarn')
57
+ return [...base, ...extras];
58
+ return [...base, '--', ...extras];
59
+ }
@@ -7,7 +7,24 @@ export interface ProjectFile {
7
7
  export interface ProjectTemplateTokens {
8
8
  readonly projectName: string;
9
9
  readonly gaonjsVersion: string;
10
+ /**
11
+ * package.json 의 `packageManager` 필드(corepack 핀 · 예 `pnpm@10.27.0`).
12
+ * 미지정 시 blessed 기본 pnpm. 선택한 pm 에 맞춰 채운다(결정 169).
13
+ */
14
+ readonly packageManager?: string;
10
15
  }
16
+ /**
17
+ * pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
18
+ * 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
19
+ * 12차 실사용 yarn 파손). 정본 버전 근거:
20
+ * · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
21
+ * · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
22
+ * 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
23
+ * · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
24
+ */
25
+ export declare const PACKAGE_MANAGER_PINS: Readonly<Record<'pnpm' | 'npm' | 'yarn', string>>;
26
+ /** blessed 기본(pm 미선택 시). */
27
+ export declare const DEFAULT_PACKAGE_MANAGER: string;
11
28
  /** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
12
29
  export declare function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string;
13
30
  /** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
@@ -12,11 +12,28 @@ import { readFileSync, readdirSync } from 'node:fs';
12
12
  import { dirname, join, posix, relative, sep } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project');
15
+ /**
16
+ * pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
17
+ * 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
18
+ * 12차 실사용 yarn 파손). 정본 버전 근거:
19
+ * · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
20
+ * · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
21
+ * 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
22
+ * · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
23
+ */
24
+ export const PACKAGE_MANAGER_PINS = {
25
+ pnpm: 'pnpm@10.27.0',
26
+ npm: 'npm@10.9.9',
27
+ yarn: 'yarn@1.22.22',
28
+ };
29
+ /** blessed 기본(pm 미선택 시). */
30
+ export const DEFAULT_PACKAGE_MANAGER = PACKAGE_MANAGER_PINS.pnpm;
15
31
  /** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
16
32
  export function renderTemplate(raw, tokens) {
17
33
  return raw
18
34
  .replaceAll('{{PROJECT_NAME}}', tokens.projectName)
19
- .replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion);
35
+ .replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
36
+ .replaceAll('{{PACKAGE_MANAGER}}', tokens.packageManager ?? DEFAULT_PACKAGE_MANAGER);
20
37
  }
21
38
  /** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
22
39
  export function listTemplateFiles(root = TEMPLATE_DIR) {
@@ -23,15 +23,39 @@ export interface ProjectFile {
23
23
  export interface ProjectTemplateTokens {
24
24
  readonly projectName: string
25
25
  readonly gaonjsVersion: string
26
+ /**
27
+ * package.json 의 `packageManager` 필드(corepack 핀 · 예 `pnpm@10.27.0`).
28
+ * 미지정 시 blessed 기본 pnpm. 선택한 pm 에 맞춰 채운다(결정 169).
29
+ */
30
+ readonly packageManager?: string
26
31
  }
27
32
 
28
33
  const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project')
29
34
 
35
+ /**
36
+ * pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
37
+ * 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
38
+ * 12차 실사용 yarn 파손). 정본 버전 근거:
39
+ * · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
40
+ * · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
41
+ * 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
42
+ * · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
43
+ */
44
+ export const PACKAGE_MANAGER_PINS: Readonly<Record<'pnpm' | 'npm' | 'yarn', string>> = {
45
+ pnpm: 'pnpm@10.27.0',
46
+ npm: 'npm@10.9.9',
47
+ yarn: 'yarn@1.22.22',
48
+ }
49
+
50
+ /** blessed 기본(pm 미선택 시). */
51
+ export const DEFAULT_PACKAGE_MANAGER = PACKAGE_MANAGER_PINS.pnpm
52
+
30
53
  /** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
31
54
  export function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string {
32
55
  return raw
33
56
  .replaceAll('{{PROJECT_NAME}}', tokens.projectName)
34
57
  .replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
58
+ .replaceAll('{{PACKAGE_MANAGER}}', tokens.packageManager ?? DEFAULT_PACKAGE_MANAGER)
35
59
  }
36
60
 
37
61
  /** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
@@ -30,3 +30,9 @@ COOKIE_SECRET=change-me-too-32-char-random-secret!!
30
30
 
31
31
  # 리슨 포트 (gaon serve --port 로 덮음).
32
32
  PORT=3000
33
+
34
+ # 클라이언트 공개 환경변수(결정 198) — `VITE_` 접두 변수만 브라우저 번들에 노출된다(그 외는
35
+ # 서버-only). `.vue`·클라 `.ts` 에서 `import { env } from 'gaonjs/vue'` 로 읽는다(import.meta.env
36
+ # 직접 사용은 vue-tsc TS1470 · doctor no-import-meta-env 가 잡음). 접두는 제거된다: 아래를 켜면
37
+ # `env.API_URL` 로 접근. `.env` 의 VITE_* 키가 `.gaon/env.d.ts` 로 물성화돼 없는 키는 컴파일 에러.
38
+ # VITE_API_URL=https://api.example.com
@@ -108,7 +108,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
108
108
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
109
109
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
110
110
 
111
- ### 2.2 `gaon doctor` 검사 25
111
+ ### 2.2 `gaon doctor` 검사 26
112
112
 
113
113
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
114
114
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -135,6 +135,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
135
135
  23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
136
136
  24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon check --fix` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
137
137
  25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
138
+ 26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
138
139
 
139
140
  ## 3. 로직 배치 One Way 판단표
140
141
 
@@ -195,7 +196,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
195
196
  ```bash
196
197
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
197
198
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
198
- gaon doctor # 정적 검사 24종 (§2.2)
199
+ gaon doctor # 정적 검사 26종 (§2.2)
199
200
  ```
200
201
 
201
202
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
85
85
 
86
86
  ```bash
87
87
  gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
88
- gaon doctor # 정적 검사 24종 (상세 AGENTS §2.2)
88
+ gaon doctor # 정적 검사 25종 (상세 AGENTS §2.2)
89
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
90
  ```
91
91
 
@@ -247,21 +247,51 @@ export default schedule((s) => {
247
247
  (`.later()`), 처리·스케줄은 워커가 한다. 스케줄이 안 도는 흔한 원인은
248
248
  `gaon work` 를 안 띄운 것이다.
249
249
 
250
+ #### 시간대 (결정 202)
251
+
252
+ `s.daily.at('04:00', Job)`·`s.cron('0 9 * * 1', Job)` 는 **서버 로컬 타임존**의
253
+ wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **잡별 타임존
254
+ 옵션이 없다** — 특정 TZ 로 돌려야 하면 워커 프로세스의 `TZ` 환경변수를 그
255
+ 타임존으로 띄운다(예: `TZ=Asia/Seoul gaon work`). 여러 인스턴스는 같은 TZ 로
256
+ 맞춘다(리더가 어느 인스턴스든 같은 wall-clock 을 봐야 한다).
257
+
258
+ #### 중첩 방지 (결정 202)
259
+
260
+ 스케줄러는 매 주기 **발행만** 한다(§7 리더). 한 주기보다 오래 걸리는 잡이
261
+ **자기 자신과 겹쳐** 실행되면 안 되면, 잡 핸들러가 `lock(key, fn, { onBusy:
262
+ 'skip' })` 으로 임계구역을 지킨다 — 이미 도는 인스턴스가 있으면 이번 틱은
263
+ 스킵된다(중복 실행 방지). 락은 `gaon work` 워커에서도 배선된다(결정 202).
264
+
250
265
  ### 6. 워커 프로세스 (`gaon work`)
251
266
 
252
267
  잡·리스너·스케줄러·아웃박스 릴레이를 한 프로세스로 조립한다. 운영
253
268
  프로세스 3종(serve·work·hub) 중 하나. SIGTERM/SIGINT 에 graceful
254
269
  drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤 종료한다.
255
270
 
271
+ #### graceful drain 계약 (결정 201)
272
+
273
+ 배포·재시작(k8s 롤링 등)에서 **SIGTERM 이 진행 중 작업을 자르지 않는다**:
274
+
275
+ - **`gaon serve`** — 인플라이트 HTTP 요청을 **완료까지 기다린 뒤** 종료한다(리셋
276
+ 안 함 · 롤링 배포 중 502 방지). 새 연결은 안 받고, 진행 요청이 끝나면 idle
277
+ keep-alive 를 닫아 종료가 즉시 완결된다(결정 201 · `forceCloseConnections:false`
278
+ + close idle 스윕).
279
+ - **`gaon work`** — 신규 잡 pull 을 멈추고 **진행 중 잡을 완료**한 뒤 종료한다
280
+ (`drainTimeoutMs` 상한 · 기본 30s). 스케줄러 리더는 즉시 반납해 다른 인스턴스가
281
+ 승계한다. drain 상한을 넘긴 잡은 ack 되지 않아 재전달(크래시 복구)된다.
282
+ - **컨테이너 기본 워커 1** — `gaon serve` 클러스터(`--workers`)도 SIGTERM 에
283
+ 워커들을 graceful drain 후 종료한다(결정 84).
284
+
256
285
  ### 7. 분산 락 (`lock()`) (결정 147)
257
286
 
258
287
  **동시 실행을 막아야 하면 `lock(key, fn)`** — 같은 `key` 에 대해 전
259
288
  인스턴스를 통틀어 동시 1개의 `fn` 만 임계구역에 들인다. **로컬 뮤텍스는
260
289
  반정본이다** — 멀티 인스턴스(워커 여러 대·serve 여러 대)에서는 프로세스마다
261
290
  따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
262
- 가 있으면 `gaon serve` 분산 락을 자동 배선한다. **`redis` 미설정 상태로
263
- `lock()` 부르면 로컬 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
264
- throw** 한다.
291
+ 가 있으면 **`gaon serve` `gaon work` 둘 다** 분산 락을 자동 배선한다(결정
292
+ 202 · wireDomain 공통 경로) 그래서 **잡·서비스 안에서도 `lock()` 을 쓸 수
293
+ 있다**(예: 중첩 방지 · §5). **`redis` 미설정 상태로 `lock()` 을 부르면 로컬
294
+ 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께 throw** 한다.
265
295
 
266
296
  ```ts
267
297
  import { lock } from 'gaonjs/async'
@@ -344,4 +374,7 @@ async create() {
344
374
  | 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
345
375
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
346
376
  | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
377
+ | 결정 200 | DLQ 재처리 `retryDlq(nats, id)` (이미 설정된 잡 전송 보존 · DLQ 재적재 후 원본 삭제) |
378
+ | 결정 201 | graceful drain 계약 (serve 인플라이트 요청 완결 · work 진행 잡 완결 · §6) |
379
+ | 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러 시간대(서버 로컬 TZ)·중첩 방지(§5·§7) |
347
380
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
@@ -251,6 +251,24 @@ import PageShell from '@shared/components/ui/PageShell.vue'
251
251
  | 원자 (18) | Button · Input · Label · Badge · Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter · Alert · AlertTitle · AlertDescription · Form · FormField · FormMessage · Dialog · Sheet |
252
252
  | 블록 (4 · 결정 106) | PageShell · PageHeader · EmptyState · Pagination |
253
253
 
254
+ **슬롯·props 요약 (첫 시도용 · 소스 안 읽어도 되게 · O-2):** 카탈로그는 이름만이라 슬롯/prop 을
255
+ 소스에서 찾아야 했다 — 자주 쓰는 표면을 여기 못박는다(전체·정확한 타입은 컴포넌트 소스가 정본).
256
+ **주의: named slot 이름이 비대칭이다** — `PageHeader` 는 `#actions`(**복수**), `EmptyState` 는
257
+ `#action`(**단수**). 기본 슬롯을 잘못 쓰면 조용히 안 그려진다.
258
+
259
+ | 컴포넌트 | props | slots | emits |
260
+ |---|---|---|---|
261
+ | **PageShell** | `size?: 'default'\|'narrow'\|'wide'\|'full'` | 기본 | — |
262
+ | **PageHeader** | `title?` · `description?` | `#title` · `#description` · **`#actions`**(복수) | — |
263
+ | **EmptyState** | `title?` · **`description?`(prop)** | `#icon` · `#title` · `#description` · **`#action`**(단수) | — |
264
+ | **Pagination** | `page`(필수) · `pageCount`(필수) · `siblings?=1` | — | `update:page` (= `v-model:page`) |
265
+ | Button | `variant?='default'` · `size?='default'` · `type?='button'` · `href?` · `external?` · `target?` | 기본 | 네이티브(예 `@click`) |
266
+ | FormField | `label?` · `error?` | 기본(컨트롤) | — |
267
+ | FormMessage | `message?` | — | — |
268
+ | Alert | `variant?: 'default'\|'destructive'` | 기본 | — |
269
+ | Badge | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'` | 기본 | — |
270
+ | Card / CardHeader / CardTitle / CardDescription / CardContent / CardFooter | — | 기본(조합) | — |
271
+
254
272
  - **블록은 성격 중립(결정 106)** — 관리자/프론트를 나누지 않고 전 앱에서 쓴다.
255
273
  `PageShell`(최대폭·여백·세로 리듬) · `PageHeader`(제목+설명+액션) · `EmptyState`
256
274
  (빈 목록) · `Pagination`(페이지 이동 · `v-model:page`). 이외 블록(DataTable·StatCard·
@@ -287,7 +305,7 @@ import PageShell from '@shared/components/ui/PageShell.vue'
287
305
  통째로 넘기면, 블록의 `:page`·`:pageCount` 가 필드명 그대로 붙는다(매핑 보일러플레이트 0).
288
306
  ```vue
289
307
  <script setup lang="ts">
290
- import { Pagination } from '@shared/components/ui'
308
+ import Pagination from '@shared/components/ui/Pagination.vue'
291
309
  import { router } from 'gaonjs/vue'
292
310
  const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
293
311
  function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
@@ -311,6 +329,37 @@ import PageShell from '@shared/components/ui/PageShell.vue'
311
329
  `tailwind.config.ts` 의 `content` 에 `./shared/**/*.{vue,ts}` 가 있는지도 확인한다
312
330
  (스캐폴드 기본값엔 이미 포함).
313
331
 
332
+ ### 9. 클라이언트 환경변수 — `env` (결정 198 · F-9 옵션 ②)
333
+
334
+ `.vue`·클라 `.ts` 에서 공개 환경변수는 **`env` 접근자**로 읽는다 — `import.meta.env`
335
+ 를 직접 쓰지 않는다. `.vue`(SFC)는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가
336
+ `import.meta` 를 **TS1470** 로 거부하기 때문이다(`gaon check` red). 프레임웍이
337
+ `import.meta.env` 를 대신 읽어 재노출하므로 페이지는 `import.meta` 를 안 쓴다.
338
+
339
+ ```vue
340
+ <script setup lang="ts">
341
+ import { env } from 'gaonjs/vue'
342
+
343
+ const api = env.API_URL // .env 의 VITE_API_URL — VITE_ 접두 제거 · 타입드
344
+ if (env.dev) console.log(env.mode) // 내장: dev·prod·mode·baseUrl(camelCase 정규화)
345
+ </script>
346
+ ```
347
+
348
+ - **노출 정책 = Vite 표준 `VITE_*`** — `.env` 의 `VITE_` 접두 변수만 클라 번들에
349
+ 노출된다(그 외 `SECRET_KEY`·`DATABASE_URL` 등은 **자동 서버-only**, 번들에 절대 안 감).
350
+ 접근자에서 접두는 제거된다: `VITE_API_URL` → `env.API_URL`.
351
+ - **타입 안전** — `.env` 의 VITE_* 키가 `.gaon/env.d.ts`(gen/dev/build/check 재생성)로
352
+ 물성화돼 `gaonjs/vue` 의 `GaonClientEnv` 를 augment 한다. `env.<없는키>` 는 컴파일
353
+ 에러(messages.d.ts 동형 · 결정 158). 키를 추가하면 `.env` 에 `VITE_...` 를 넣는다.
354
+ - **`.env` 필수** — 프론트 앱이 있으면 `.env` 가 없을 때 gen/dev/build/check 가 명확히
355
+ 실패한다(수리 안내: `cp .env.example .env`). `.env` 는 앱 실행 전제다.
356
+ - **내장 필드**: `env.mode`(빌드 모드)·`env.dev`/`env.prod`(불리언)·`env.baseUrl`
357
+ (앱 base · web='/'·admin='/admin/'). `import.meta.env.MODE/DEV/PROD/BASE_URL` 대체.
358
+ - **doctor**: `.vue` 에서 `import.meta.env` 직접 사용은 **no-import-meta-env** 가
359
+ error 로 잡아 `env` 접근자로 안내한다(TS1470 을 읽기 전에).
360
+ - 서버 코드(컨트롤러·domain)의 환경변수는 이 접근자가 아니라 서버 env(`gaon.config.ts`
361
+ 의 `env('KEY')`·`process.env`)로 읽는다 — `env`(gaonjs/vue)는 **클라 전용**이다.
362
+
314
363
  ## 정본 예시
315
364
 
316
365
  ```vue
@@ -406,6 +455,8 @@ async function runSearch(q: string) {
406
455
  | 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
407
456
  | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
408
457
  | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
458
+ | 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
459
+ | 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
409
460
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
410
461
 
411
462
  ## `@gaonjs/seal` 켠 앱의 프론트
@@ -32,6 +32,29 @@ t('nav.home') // 중첩은 점 표기
32
32
  | 지원 언어 | `languages(): string[]` | 설정된 supportedLngs |
33
33
  | 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
34
34
 
35
+ ### 1.5 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
36
+
37
+ 복수형은 카탈로그에 **접미사 키**(`<키>_one`·`<키>_other`)를 두고 `t('<키>', { count })`
38
+ 로 부른다 — i18next 가 `count` 와 로케일의 CLDR 규칙으로 알맞은 접미사를 고른다. 타입은
39
+ **base 키**(`<키>`)로 검사한다(생성기가 접미사 키에서 base 키를 함께 노출 · 결정 181).
40
+
41
+ ```json
42
+ // locales/en.json — 영어는 단수/복수 구분(_one·_other)
43
+ { "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
44
+ // locales/ko.json — 한국어는 복수 구분 없음(_other 만)
45
+ { "cart": { "items_other": "상품 {{count}}개" } }
46
+ ```
47
+
48
+ ```ts
49
+ t('cart.items', { count: 1 }) // en → "1 item" · ko → "상품 1개"
50
+ t('cart.items', { count: 5 }) // en → "5 items" · ko → "상품 5개"
51
+ ```
52
+
53
+ - **base 키로 부른다** — `t('cart.items', { count })`. `t('cart.items_one')` 처럼 접미사를
54
+ 직접 부르면 복수 선택이 안 된다(안티패턴).
55
+ - 로케일마다 필요한 접미사만 둔다(영어 `_one`·`_other` / 한국어·일본어 `_other`). 기준
56
+ 로케일(§4)에 있는 키가 타입 유니온이 되므로 **기준 로케일 카탈로그에 복수형 키를 둔다**.
57
+
35
58
  ### 2. 요청별 로케일 자동 협상 (결정 159)
36
59
 
37
60
  `gaon.config.ts` 에 `i18n` 설정이 있으면 `gaon serve`/`gaon dev` 가 **매 요청**
@@ -97,6 +120,9 @@ export function greetLine(name: string): string {
97
120
  전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`.
98
121
  - **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })` 로
99
122
  명시한다(`agents/mail.md` · 결정 160).
123
+ - **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
124
+ 를 `locales/` 에 넣으면 필드별 사유가 요청 로케일로 번역된다(`agents/web.md` §4.1 · 결정 183).
125
+ 프레임웍은 코드만 노출하고 번역은 앱 몫이다(미제공 시 내장 fallback).
100
126
 
101
127
  ## 관련 결정 번호
102
128
 
@@ -57,6 +57,37 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
57
57
  ·`.env.example` 에 mail 블록이 이미 있어(결정 160) `cp .env.example .env` 후 바로 돈다.
58
58
  운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
59
59
 
60
+ ### 5. `gaon.config.ts` 의 `mail` 블록 (env-gated)
61
+
62
+ 메일러는 루트 `gaon.config.ts` 의 `mail` 블록으로 배선된다 — `db`·`redis`·`nats`
63
+ 동형으로 **env-gated**(SMTP_HOST 없으면 미배선). 스캐폴드(`gaon new`)가 아래 블록을
64
+ 이미 넣어 둔다(결정 160). 발신자 기본값은 **`defaultFrom`**(메시지 `from` 아님 · `from`
65
+ 은 개별 메시지 override).
66
+
67
+ ```ts
68
+ // gaon.config.ts
69
+ export default defineConfig({
70
+ mail: process.env.SMTP_HOST
71
+ ? {
72
+ host: process.env.SMTP_HOST,
73
+ port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 1025,
74
+ secure: process.env.SMTP_SECURE === 'true', // 운영 TLS
75
+ user: process.env.SMTP_USER, // 선택(인증 SMTP)
76
+ pass: process.env.SMTP_PASS, // 선택
77
+ defaultFrom: process.env.MAIL_FROM, // 발신자 기본값(from 없는 메시지)
78
+ }
79
+ : undefined,
80
+ })
81
+ ```
82
+
83
+ | 필드 | 타입 | 비고 |
84
+ |---|---|---|
85
+ | `host` | `string` | SMTP 호스트(dev = MailPit `127.0.0.1`) |
86
+ | `port` | `number` | SMTP 포트(dev MailPit = 1025) |
87
+ | `secure?` | `boolean` | TLS(운영). 생략 = false |
88
+ | `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능) |
89
+ | `defaultFrom` | `string` | 발신자 기본값 — 메시지에 `from` 이 없을 때. `configureMailer({ defaultFrom })` 와 동치 |
90
+
60
91
  ## 정본 예시
61
92
 
62
93
  ```ts
@@ -186,6 +186,15 @@ export function useRoom(roomId: number) {
186
186
  - 허브가 죽었다 재시작하면 같은 포트로 재bind 하고 KV 에서 상태를
187
187
  복원한다.
188
188
  - ping 무활동 타임아웃은 네트워크 파티션 백스톱이다.
189
+ - **fail-fast — 리더가 됐는데 포트를 못 잡으면 즉시 종료한다(결정 207).**
190
+ 리스는 얻었으나 TCP 포트 bind 에 실패하면(포트를 다른 허브·orphan·
191
+ 오설정이 점유) 허브는 **좀비 리더**(리스 보유·프레즌스 서빙 불가)가 되지
192
+ 않도록 리스를 사임하고 `process.exit(1)` 한다. 조용히 프레즌스가 죽는
193
+ 대신 명확히 종료해 systemd/pm2 가 재시작하게 하고, 대기 인스턴스가 즉시
194
+ 승계한다. **운영 함정**: 한 호스트에 허브를 여러 개 띄우면 포트가
195
+ 겹친다 — 인스턴스마다 다른 `GAON_HUB_PORT` 를 주거나 호스트를 분리한다.
196
+ `gaon dev` 는 내장 허브를 기본 포트로 띄우므로, 같은 호스트에서 별도
197
+ `gaon hub` 를 돌릴 땐 포트를 바꾼다.
189
198
 
190
199
  ## 정본 예시
191
200
 
@@ -255,6 +264,7 @@ export default channel({
255
264
  | §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
256
265
  | 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
257
266
  | 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
267
+ | 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
258
268
 
259
269
  ## `@gaonjs/seal` 켠 앱의 채널
260
270
 
@@ -24,10 +24,13 @@
24
24
  매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
25
25
  - `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
26
26
  테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
27
- `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 그래서 개발용 `gaon work`
28
- 있어도 테스트 잡을 훔치지 않고, 테스트가 남긴 잡을 개발 워커가 처리하지도
29
- 않는다**테스트 전에 워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이
30
- 격리가 없어 개발 스택과 섞이니 `gaon test` 쓴다.)
27
+ `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
28
+ (스케줄러 리스 `gaon_lease`·프레즌스·허브)에도 적용돼(결정 203) `test_gaon_lease`
29
+ 처럼 격리된다 스트림뿐 아니라 KV 개발·운영 스택과 겹친다. 그래서
30
+ 개발용 `gaon work` 있어도 테스트 잡을 훔치지 않고 스케줄러 리더 경합도
31
+ 안 생기며, 테스트가 남긴 잡을 개발 워커가 처리하지도 않는다 — **테스트 전에
32
+ 워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이 격리가 없어 개발 스택과
33
+ 섞이니 `gaon test` 를 쓴다.)
31
34
  - SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
32
35
  아니다.
33
36
 
@@ -256,5 +259,6 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
256
259
  | 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
257
260
  | 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
258
261
  | 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
262
+ | 결정 203 | 같은 접두를 **NATS KV 버킷**에도 적용(`gaon_lease`·프레즌스·허브 → `test_gaon_lease` 등) — 스트림만 격리하던 결정 130 의 빈틈(KV 미격리)을 메움. 스케줄러 리스 경합·크로스-프리픽스 KV 누출 방지 |
259
263
  | §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
260
264
  | 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |