@gaonjs/cli 0.38.0 → 0.39.0

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.
@@ -0,0 +1,6 @@
1
+ import type { RuleReport } from './types.js';
2
+ /**
3
+ * locales/ 의 로케일 간 키 diff 를 계산해 부분 누락을 경고로 낸다. 로케일이 0·1개면
4
+ * 비교 대상이 없어 건너뛴다(i18n 미사용·단일 로케일 프로젝트는 무소음).
5
+ */
6
+ export declare function checkLocaleParity(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,68 @@
1
+ // @gaonjs/cli · doctor · 로케일 키 부분 누락 검출 (결정 216 · 13차 W4 · 경고)
2
+ //
3
+ // .gaon/messages.d.ts 의 키 유니온은 **기준 로케일 하나**에서 뽑는다(generator.ts ·
4
+ // renderMessagesDts). 그래서 어떤 키가 특정 로케일에만 빠져 있으면 타입 검사는 통과하고,
5
+ // 런타임에 그 로케일 사용자는 fallback 번역(대개 다른 언어)을 조용히 보게 된다 — 타입도
6
+ // 못 잡고 화면에도 티가 안 나는 사각(결정 216). 이 검사가 locales/ 를 훑어 로케일 간 키
7
+ // 집합을 비교해, 다른 로케일엔 있는데 이 로케일엔 없는 키를 파일·키로 경고한다.
8
+ //
9
+ // 경고(error 아님): 부분 누락은 fallback 으로 화면이 깨지진 않는 소프트 결함이라
10
+ // 빌드를 세우지 않는다. --json 은 detail.locale·detail.missing 으로 구조화한다.
11
+ import { existsSync } from 'node:fs';
12
+ import { join, relative } from 'node:path';
13
+ import { loadLocales, flattenKeys } from '@gaonjs/i18n';
14
+ // 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
15
+ const MAX_KEYS_SHOWN = 20;
16
+ // i18next 복수형 접미사(결정 181 · generator.ts 와 동일 규약). 로케일마다 필요한 접미사만
17
+ // 두는 것이 정상이라(en `_one`·`_other` / ko `_other`), 접미사 변형 차이는 부분 누락이
18
+ // 아니다 — base 키로 정규화해 비교해야 정당한 복수형 카탈로그에 오탐(경고)을 안 낸다.
19
+ const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
20
+ /** 키를 복수형 base 로 정규화한다(접미사 제거 · 비복수 키는 그대로). */
21
+ function pluralBase(key) {
22
+ return key.replace(PLURAL_SUFFIX, '');
23
+ }
24
+ /**
25
+ * locales/ 의 로케일 간 키 diff 를 계산해 부분 누락을 경고로 낸다. 로케일이 0·1개면
26
+ * 비교 대상이 없어 건너뛴다(i18n 미사용·단일 로케일 프로젝트는 무소음).
27
+ */
28
+ export async function checkLocaleParity(cwd) {
29
+ const localesDir = join(cwd, 'locales');
30
+ if (!existsSync(localesDir))
31
+ return { rule: 'locale-parity', issues: [] };
32
+ const resources = loadLocales(localesDir);
33
+ const langs = Object.keys(resources).sort();
34
+ if (langs.length < 2)
35
+ return { rule: 'locale-parity', issues: [] };
36
+ // 로케일별 키 집합과 전체 union(어느 로케일에든 등장한 키의 합집합)을 만든다.
37
+ // 복수형 접미사는 base 로 정규화해 로케일별 정당한 복수형 차이를 오탐으로 잡지 않는다.
38
+ const keysByLang = new Map();
39
+ const union = new Set();
40
+ for (const lng of langs) {
41
+ const keys = new Set(flattenKeys(resources[lng].translation).map(pluralBase));
42
+ keysByLang.set(lng, keys);
43
+ for (const k of keys)
44
+ union.add(k);
45
+ }
46
+ const issues = [];
47
+ for (const lng of langs) {
48
+ const have = keysByLang.get(lng);
49
+ const missing = [...union].filter((k) => !have.has(k)).sort();
50
+ if (missing.length === 0)
51
+ continue;
52
+ const rel = relative(cwd, join(localesDir, `${lng}.json`));
53
+ const shown = missing.slice(0, MAX_KEYS_SHOWN);
54
+ const more = missing.length - shown.length;
55
+ const list = shown.map((k) => `'${k}'`).join(', ') + (more > 0 ? ` …외 ${more}개` : '');
56
+ issues.push({
57
+ rule: 'locale-parity',
58
+ level: 'warning',
59
+ file: rel,
60
+ message: `로케일 '${lng}' 에 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel}\n` +
61
+ ` 누락 키: ${list}\n` +
62
+ `→ ${rel} 에 이 키들을 채우세요 — 없으면 '${lng}' 사용자에게 fallback(대개 다른 언어)\n` +
63
+ ` 번역이 조용히 노출됩니다. messages.d.ts 는 기준 로케일 기준이라 컴파일로 못 잡습니다(결정 216).`,
64
+ detail: { locale: lng, missing },
65
+ });
66
+ }
67
+ return { rule: 'locale-parity', issues };
68
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -22,13 +22,14 @@ export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/p
22
22
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
23
23
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
24
24
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
25
+ export { checkLocaleParity } from './doctor/locale-parity.js';
25
26
  export { renderHuman, renderJson } from './doctor/reporter.js';
26
27
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
27
28
  /**
28
- * 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
29
+ * 실행할 검사 이름. 지정 없음(undefined) = 27개 모두.
29
30
  */
30
31
  /**
31
- * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
32
+ * doctor 정적 검사 27종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
32
33
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
33
34
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
34
35
  */
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 26 검사를 조립한다:
4
+ * 27 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
@@ -28,6 +28,7 @@
28
28
  * 24) seal-security (결정 121 · seal 클라 배선 · 보안 역전)
29
29
  * 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
30
30
  * 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
31
+ * 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
31
32
  *
32
33
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
33
34
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -66,6 +67,7 @@ import { checkSealSecurity } from './doctor/seal-security.js';
66
67
  import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
67
68
  import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
68
69
  import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
70
+ import { checkLocaleParity } from './doctor/locale-parity.js';
69
71
  import { renderHuman, renderJson } from './doctor/reporter.js';
70
72
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
71
73
  import { makeResult, } from './doctor/types.js';
@@ -91,13 +93,14 @@ export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/p
91
93
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
92
94
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
93
95
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
96
+ export { checkLocaleParity } from './doctor/locale-parity.js';
94
97
  export { renderHuman, renderJson } from './doctor/reporter.js';
95
98
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
96
99
  /**
97
- * 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
100
+ * 실행할 검사 이름. 지정 없음(undefined) = 27개 모두.
98
101
  */
99
102
  /**
100
- * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
103
+ * doctor 정적 검사 27종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
101
104
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
102
105
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
103
106
  */
@@ -128,6 +131,7 @@ export const ALL_RULES = [
128
131
  'seal-security',
129
132
  'schema-relations',
130
133
  'no-import-meta-env',
134
+ 'locale-parity',
131
135
  ];
132
136
  const CHECKERS = {
133
137
  'response-mixing': checkResponseMixing,
@@ -156,6 +160,7 @@ const CHECKERS = {
156
160
  'seal-security': checkSealSecurity,
157
161
  'schema-relations': checkSchemaRelations,
158
162
  'no-import-meta-env': checkNoImportMetaEnv,
163
+ 'locale-parity': checkLocaleParity,
159
164
  };
160
165
  /**
161
166
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/index.js CHANGED
@@ -109,7 +109,7 @@ function renderHelp(version = VERSION) {
109
109
  " gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
110
110
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
111
111
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
112
- " gaon doctor 정적 검사 (25 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계)",
112
+ " gaon doctor 정적 검사 (27 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지)",
113
113
  " gaon doctor --json 자동화용 JSON 출력",
114
114
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
115
115
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -146,7 +146,7 @@ function renderHelp(version = VERSION) {
146
146
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
147
147
  */
148
148
  export function parseDoctorChecks(argv) {
149
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 26종)를 단일 출처로 쓴다 — 과거
149
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 27종)를 단일 출처로 쓴다 — 과거
150
150
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
151
151
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
152
152
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -307,7 +307,7 @@ export function runCli(argv, opts = {}) {
307
307
  });
308
308
  return;
309
309
  }
310
- // `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
310
+ // `gaon doctor` — 정적 검사(M9-E · 27 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
311
311
  // 선택 실행, --json 은 자동화 파싱용.
312
312
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
313
313
  if (argv[0] === "doctor") {
@@ -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` 검사 26
111
+ ### 2.2 `gaon doctor` 검사 27
112
112
 
113
113
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
114
114
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -136,6 +136,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
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
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)
139
+ 27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
139
140
 
140
141
  ## 3. 로직 배치 One Way 판단표
141
142
 
@@ -196,7 +197,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
196
197
  ```bash
197
198
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
198
199
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
199
- gaon doctor # 정적 검사 26종 (§2.2)
200
+ gaon doctor # 정적 검사 27종 (§2.2)
200
201
  ```
201
202
 
202
203
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -50,6 +50,9 @@ const props = pageProps<'web:posts#index'>()
50
50
  // <template> 에서 shared.currentUser?.name · shared.csrf · shared.flash.success
51
51
  // 앱이 app.config sharedProps 로 등록한 키(locale·theme 등)도 같은 자리에서 읽힌다(결정 150).
52
52
  ```
53
+ **i18n 문구도 이 경로로 온다** — Vue 는 `t()` 를 직접 부르지 않는다(서버 ALS 전용).
54
+ 페이지 문구는 컨트롤러 render props, nav·레이아웃 같은 앱 chrome 문구는 `sharedProps`
55
+ 로 서버가 번역해 흘려보낸다(`agents/i18n.md` §5 · 결정 213).
53
56
  `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
54
57
  `useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
55
58
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
@@ -457,6 +460,7 @@ async function runSearch(q: string) {
457
460
  | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
458
461
  | 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
459
462
  | 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
463
+ | 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
460
464
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
461
465
 
462
466
  ## `@gaonjs/seal` 켠 앱의 프론트
@@ -96,6 +96,112 @@ async setLocale() {
96
96
  카탈로그에 없는 키·오타가 `gaon check` 에서 걸린다(카탈로그가 없으면 키는
97
97
  `string` 폴백). 이 파일은 자동 생성이니 직접 수정하지 않는다.
98
98
 
99
+ ### 5. Vue 페이지 소비 — 서버가 번역해 흘려보낸다 (결정 213)
100
+
101
+ **`t()` 는 서버 전용이다** — 요청 컨텍스트(ALS)의 로케일을 읽으므로 `gaonjs/vue`
102
+ 는 `t()`·`useT()` 를 **내보내지 않는다**(설계 의도). Vue 페이지·컴포넌트는 번역을
103
+ 직접 하지 않고, **서버에서 번역한 문자열을 받아 쓴다.** 번역 소스가 서버 하나로
104
+ 고정되므로(The One Way) 카탈로그가 클라 번들에 중복 실리지 않고, 키 타입 검사
105
+ (§4)도 서버 한 곳에서만 성립한다. 경로는 두 가지다:
106
+
107
+ **(a) 페이지 문구 = 컨트롤러 render props.** 그 페이지에서만 쓰는 문구는 컨트롤러가
108
+ `t()` 로 번역해 `this.render` props 로 넘긴다 — 페이지는 이미 현지화된 문자열을 받는다.
109
+
110
+ ```ts
111
+ // apps/web/controllers/posts.ts
112
+ import { t } from 'gaonjs/i18n'
113
+ async index() {
114
+ return this.render('Posts/Index', {
115
+ heading: t('posts.heading'), // 서버가 요청 로케일로 번역
116
+ empty: t('posts.empty'),
117
+ posts: (await Post.latest().all()).map((p) => ({ id: String(p.id), title: p.title })),
118
+ })
119
+ }
120
+ ```
121
+
122
+ ```vue
123
+ <!-- apps/web/pages/Posts/Index.vue -->
124
+ <script setup lang="ts">
125
+ import { pageProps } from 'gaonjs/vue'
126
+ const props = pageProps<'web:posts#index'>() // heading·empty 가 현지화된 채로 온다
127
+ </script>
128
+ <template>
129
+ <h1>{{ props.heading }}</h1>
130
+ <p v-if="!props.posts.length">{{ props.empty }}</p>
131
+ </template>
132
+ ```
133
+
134
+ **(b) 앱 chrome(전 페이지 공용 문구) = `app.config.ts` 의 `sharedProps` (결정 150 동형).**
135
+ nav 라벨·레이아웃 문구처럼 앱의 **모든** 페이지가 쓰는 chrome 문구는 컨트롤러마다
136
+ 넘기지 않고 `sharedProps` 로 한 번 등록한다 — `useShared()` 로 라우트 키 없이 읽힌다
137
+ (레이아웃·공용 컴포넌트에서 특히 유용 · `agents/frontend.md` §1·web.md §4.2).
138
+
139
+ ```ts
140
+ // apps/web/app.config.ts
141
+ import { t } from 'gaonjs/i18n'
142
+ export default defineAppConfig({
143
+ sharedProps: () => ({
144
+ nav: { home: t('nav.home'), posts: t('nav.posts') }, // 요청 로케일로 번역돼 전 렌더에 주입
145
+ }),
146
+ })
147
+ ```
148
+
149
+ 읽는 쪽은 타입 브리지를 **선언 병합**으로 확장한다(코어 3종 currentUser·csrf·flash 는
150
+ 고정 · 앱 키만 추가):
151
+
152
+ ```ts
153
+ // shared/gaon-shared.d.ts (또는 아무 .d.ts)
154
+ import 'gaonjs/vue'
155
+ declare module 'gaonjs/vue' {
156
+ interface GaonSharedProps { nav: { home: string; posts: string } }
157
+ }
158
+ ```
159
+
160
+ ```vue
161
+ <!-- apps/web/layouts/Default.vue -->
162
+ <script setup lang="ts">
163
+ import { useShared, Link } from 'gaonjs/vue'
164
+ const shared = useShared() // 타입 안전 · 반응형
165
+ </script>
166
+ <template>
167
+ <nav>
168
+ <Link href="/">{{ shared.nav.home }}</Link>
169
+ <Link href="/posts">{{ shared.nav.posts }}</Link>
170
+ </nav>
171
+ </template>
172
+ ```
173
+
174
+ - **서버 주도가 The One Way** — 로케일 전환(`this.setLocale`·§3) 후 다음 요청의
175
+ render props·sharedProps 가 새 로케일로 다시 번역돼 흘러든다. 클라가 카탈로그를
176
+ 들고 다시 번역할 일이 없다(같은 정답이 둘로 갈라지지 않는다).
177
+ - **함수형 `sharedProps` 로 매 요청 번역** — `sharedProps: () => ({...})` 는 요청마다
178
+ 실행되므로 `t()` 가 그 요청의 로케일을 읽는다. 상수 객체로 굳히면 첫 로케일에
179
+ 박제된다(변이 축은 함수로 계산 · web.md §4.2).
180
+
181
+ ### 6. `<html lang>` 은 요청 로케일을 자동으로 따른다 (결정 214)
182
+
183
+ `i18n` 이 설정된 프로젝트는 최초 문서 응답(Inertia 셸)의 `<html lang>` 이 그 요청의
184
+ 협상 로케일로 자동 치환된다 — `apps/<앱>/index.html` 의 `<html lang="ko">` 는 정적
185
+ 기본값일 뿐이고, `Accept-Language: ja`(또는 `gaon_locale=ja` 쿠키·세션)면 응답 셸은
186
+ `<html lang="ja">` 로 나간다. 손으로 배선할 것이 없다(자동 · SEO·스크린리더·`:lang`
187
+ CSS 가 올바른 언어를 안다). 비-i18n 프로젝트는 템플릿 정적값을 그대로 유지한다.
188
+
189
+ - **템플릿의 `lang` 을 요청마다 바꾸려 하지 말 것** — `index.html` 은 정적 기본값만
190
+ 둔다. 실제 치환은 서버 셸 조립이 한다(SPA X-Inertia JSON 응답은 대상 아님 · 최초
191
+ 문서에만 `<html>` 이 있다).
192
+ - **로케일 전환(`this.setLocale`·§3) 후에도 추종** — 다음 요청의 협상 로케일이
193
+ 쿠키/세션으로 실려 셸 `lang` 도 함께 바뀐다.
194
+
195
+ ### 7. 로케일 커버리지 — `locale-parity` 경고 (결정 216)
196
+
197
+ `messages.d.ts` 의 키 유니온은 **기준 로케일 하나**에서 나온다(§4). 그래서 어떤 키를
198
+ `ko.json`·`en.json` 에는 넣고 `ja.json` 에만 빠뜨리면 **컴파일은 통과**하고, 런타임에
199
+ 일본어 사용자만 fallback(대개 다른 언어) 번역을 조용히 본다 — 타입도 화면도 못 잡는
200
+ 사각이다. `gaon doctor`/`gaon check` 의 `locale-parity`(§2.2 27번)가 `locales/` 의
201
+ 로케일 간 키 diff 를 계산해 빠진 파일·키를 **경고**로 짚는다(`--json` 은 `detail.missing`).
202
+ 로케일이 0·1개면 비교 대상이 없어 무소음이다. 경고이므로 빌드를 세우진 않지만, 로케일을
203
+ 추가할 때 키를 전 로케일에 나란히 채워 커버리지를 맞추는 것이 관례다.
204
+
99
205
  ## 정본 예시
100
206
 
101
207
  ```ts
@@ -123,6 +229,10 @@ export function greetLine(name: string): string {
123
229
  - **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
124
230
  를 `locales/` 에 넣으면 필드별 사유가 요청 로케일로 번역된다(`agents/web.md` §4.1 · 결정 183).
125
231
  프레임웍은 코드만 노출하고 번역은 앱 몫이다(미제공 시 내장 fallback).
232
+ - **Vue 에서 `t()` 를 부르지 말 것** — `gaonjs/vue` 에는 `t()`·`useT()` 가 없다(서버 ALS
233
+ 전용 · 결정 213). 페이지 문구는 컨트롤러 render props, 앱 chrome 은 `sharedProps` 로
234
+ 서버가 번역해 흘려보낸다(§5). 클라 번역 접근자를 자작하지 않는다(카탈로그 이중 존재·
235
+ 번들 비용).
126
236
 
127
237
  ## 관련 결정 번호
128
238
 
@@ -131,3 +241,6 @@ export function greetLine(name: string): string {
131
241
  | §7 (v0.15) | i18n 배터리 · locales/ 카탈로그 · t() |
132
242
  | 결정 158 (13차 W2) | `.gaon/messages.d.ts` 키 타입 브리지 — 없는 키 컴파일 에러 |
133
243
  | 결정 159 (13차 W1) | 요청별 로케일 자동 협상(wireGaon onRequest) · `this.setLocale` · detect 설정 |
244
+ | 결정 213 | Vue 클라 소비 = 서버 주도 render props/sharedProps 만(§5) · `t()`·`useT()` 클라 미노출(서버 ALS 전용) · sharedProps 는 결정 150 동형 |
245
+ | 결정 214 (13차 W2) | 최초 문서 셸 `<html lang>` 이 요청 협상 로케일 자동 추종(§6) · 템플릿 정적값은 기본값 · 비-i18n 무회귀 |
246
+ | 결정 216 (13차 W4) | `locale-parity` doctor 경고(§7) — 로케일 간 키 부분 누락 = fallback 조용 노출 · 기준 로케일 유니온의 사각 |
@@ -126,6 +126,23 @@ describe('PostPublished (실 NATS JetStream)', () => {
126
126
  확증하려면 `maxDeliver: 1`(첫 실패에서 즉시 드롭).
127
127
  - `configureEvents` 는 헬퍼가 대신 해 주고, 끝나면 하네스 배선을 복원한다
128
128
  (`expectJobProcessed` 와 동형 · 자기 nats 를 `close()` 해도 다음 테스트 무영향).
129
+ - **`service()` 안에서 emit 한 이벤트도 그대로 확증된다(결정 215).** 서비스 트랜잭션 안의
130
+ `emit` 은 즉시 NATS 로 가지 않고 같은 트랜잭션으로 `_gaon_outbox` 에 스테이징된다
131
+ (커밋과 발행의 원자성 · 결정 144). 운영에선 `gaon work` 릴레이가 이를 NATS 로 옮기지만
132
+ 테스트엔 릴레이가 없다 — 그래서 `expectEventProcessed` 가 **`trigger` 완료 직후 아웃박스를
133
+ 1회 자동 드레인**해 리스너가 받게 한다. 테스트 작성자는 emit 이 직접이든 `service()`
134
+ 경유든 **같은 헬퍼만** 쓴다(The One Way · 손으로 릴레이를 돌릴 필요 없음). 자동 드레인은
135
+ 표준 하네스(`connectTestDatabase` · `config.nats` + `main` 커넥션)가 배선하므로, `gaon test`
136
+ 로 도는 통합 테스트에서 그냥 성립한다.
137
+
138
+ ```ts
139
+ // service() 가 emit 하는 흐름도 같은 한 줄로 확증된다(트리거만 서비스 호출로 바꾸면 됨).
140
+ await expectEventProcessed(PostPublished, () => PublishPost.call({ postId: 1n }), { nats })
141
+ ```
142
+
143
+ - **타이밍 제어가 필요한 다단계 시나리오**만 명시 탈출구 `drainOutbox()`(`gaonjs/testing`)를
144
+ 직접 부른다 — 스테이징된 아웃박스 행을 즉시 릴레이하고 발행 건수를 돌려준다. 기본 경로엔
145
+ 필요 없다(자동 드레인이 The One Way).
129
146
 
130
147
  ### 5. DB 테스트 격리 — `gaon test` + `test/setup.ts` (결정 111)
131
148
 
@@ -256,6 +273,7 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
256
273
  | 결정 | 내용 |
257
274
  |---|---|
258
275
  | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
276
+ | 결정 215 (13차 W3) | `expectEventProcessed` 가 trigger 뒤 아웃박스 1회 자동 드레인 — `service()` 경유 emit(결정 144 스테이징)도 같은 헬퍼로 확증(§4.1) · 명시 탈출구 `drainOutbox()` |
259
277
  | 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
260
278
  | 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
261
279
  | 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.12.0",
31
30
  "@gaonjs/data": "0.16.1",
32
- "@gaonjs/config": "0.15.2",
33
- "@gaonjs/core": "0.2.2",
31
+ "@gaonjs/async": "0.13.0",
34
32
  "@gaonjs/i18n": "0.2.1",
35
- "@gaonjs/web": "0.18.1",
36
- "@gaonjs/mail": "0.2.1"
33
+ "@gaonjs/config": "0.16.0",
34
+ "@gaonjs/web": "0.19.0",
35
+ "@gaonjs/mail": "0.2.1",
36
+ "@gaonjs/core": "0.2.2"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""