@gaonjs/cli 0.61.0 → 0.62.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 (31) hide show
  1. package/dist/commands/check.js +8 -3
  2. package/dist/dev.d.ts +17 -4
  3. package/dist/dev.js +10 -3
  4. package/dist/doctor/fixers/i18n-layout.d.ts +7 -0
  5. package/dist/doctor/fixers/i18n-layout.js +40 -0
  6. package/dist/doctor/fixers/index.d.ts +1 -0
  7. package/dist/doctor/fixers/index.js +13 -0
  8. package/dist/doctor/i18n-app-scope.d.ts +8 -0
  9. package/dist/doctor/i18n-app-scope.js +178 -0
  10. package/dist/doctor/i18n-layout.d.ts +4 -0
  11. package/dist/doctor/i18n-layout.js +47 -0
  12. package/dist/doctor/locale-parity.js +21 -6
  13. package/dist/doctor/types.d.ts +1 -1
  14. package/dist/doctor.d.ts +3 -1
  15. package/dist/doctor.js +13 -1
  16. package/dist/index.js +2 -2
  17. package/dist/messages-gen.d.ts +28 -4
  18. package/dist/messages-gen.js +137 -17
  19. package/dist/templates/project/AGENTS.md.tpl +4 -2
  20. package/dist/templates/project/agents/frontend.md.tpl +8 -4
  21. package/dist/templates/project/agents/i18n.md.tpl +211 -211
  22. package/dist/templates/project/apps/web/locales/en.json.tpl +3 -0
  23. package/dist/templates/project/apps/web/locales/ko.json.tpl +3 -0
  24. package/dist/templates/project/apps/web/main.ts.tpl +7 -0
  25. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
  26. package/dist/templates/project/gaon.config.ts.tpl +7 -0
  27. package/dist/templates/project/locales/en/backend.json.tpl +4 -0
  28. package/dist/templates/project/locales/en/frontend.json.tpl +8 -0
  29. package/dist/templates/project/locales/ko/backend.json.tpl +4 -0
  30. package/dist/templates/project/locales/ko/frontend.json.tpl +7 -0
  31. package/package.json +6 -6
@@ -255,15 +255,20 @@ function verifyI18nCatalogs(cwd) {
255
255
  const resources = existsSync(abs) ? loadLocales(abs) : {};
256
256
  if (Object.keys(resources).length === 0) {
257
257
  return (`i18n 이 설정됐지만 로케일 카탈로그가 비어 있습니다: ${abs}\n` +
258
- ` → ${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}.json 을 만들거나(최소 fallbackLng 카탈로그),\n` +
258
+ ` → ${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}/frontend.json (화면 문구) 또는 ` +
259
+ `${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}/backend.json (서버 전용 문구)을 만드세요 — 결정 454.\n` +
260
+ ` → 단일 파일 레이아웃(${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}.json)도 계속 지원합니다(서버 전용으로 취급).\n` +
259
261
  ` → gaon.config.ts 의 i18n.dir 이 실제 카탈로그 폴더를 가리키는지 확인하세요.\n` +
260
262
  ` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
261
263
  }
262
264
  const wanted = [...(cfg.fallbackLng ? [cfg.fallbackLng] : []), ...(cfg.supportedLngs ?? [])];
263
265
  const missing = wanted.filter((lng, i, arr) => arr.indexOf(lng) === i && !resources[lng]);
264
266
  if (missing.length > 0) {
265
- return (`i18n 설정이 지목한 로케일의 카탈로그 파일이 없습니다: ${missing.join(', ')}\n` +
266
- ` → ${dir}/ 에 ${missing.map((l) => `${l}.json`).join(' · ')} 추가하거나,\n` +
267
+ return (`i18n 설정이 지목한 로케일의 카탈로그가 없습니다: ${missing.join(', ')}\n` +
268
+ ` → ${dir}/ 에 ${missing.map((l) => `${l}/frontend.json`).join(' · ')} (또는 ${missing
269
+ .map((l) => `${l}/backend.json`)
270
+ .join(' · ')})을 추가하거나,\n` +
271
+ ` → 단일 파일 레이아웃이면 ${missing.map((l) => `${l}.json`).join(' · ')} 을 추가하거나,\n` +
267
272
  ` → gaon.config.ts 의 fallbackLng/supportedLngs 에서 해당 로케일을 제거하세요.\n` +
268
273
  ` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
269
274
  }
package/dist/dev.d.ts CHANGED
@@ -4,6 +4,16 @@ export interface DevApp {
4
4
  readonly appDir: string;
5
5
  /** apps/<app>/.gaon/routes.d.ts */
6
6
  readonly routesOut: string;
7
+ /** apps/<app>/locales (앱 전용 frontend 카탈로그 · 결정 454). */
8
+ readonly localesDir: string;
9
+ /** apps/<app>/.gaon/messages.catalog.ts (클라 카탈로그 값 모듈 · 결정 454). */
10
+ readonly catalogOut: string;
11
+ }
12
+ /** 메시지 축이 카탈로그 모듈을 낼 앱 참조(결정 454 · messages-gen.MessagesApp 과 구조 호환). */
13
+ export interface MessagesAppRef {
14
+ readonly name: string;
15
+ readonly localesDir: string;
16
+ readonly catalogOut: string;
7
17
  }
8
18
  export interface DevLayout {
9
19
  /** domain/schema (없으면 undefined — tables 워치 생략). */
@@ -28,8 +38,11 @@ export interface DevDeps {
28
38
  readonly layout: DevLayout;
29
39
  regenerateTables(schemaDir: string, out: string): Promise<unknown>;
30
40
  regenerateRoutes(appDir: string, out: string): Promise<unknown>;
31
- /** locales/ → .gaon/messages.d.ts (결정 158 · W2 · 기준 로케일 = 결정 352). i18n 축을 쓰는 호출자만 준다. */
32
- regenerateMessages?(localesDir: string, out: string, baseLng?: string): unknown;
41
+ /**
42
+ * locales/ → .gaon/messages.d.ts (결정 158 · W2 · 기준 로케일 = 결정 352) + 앱별
43
+ * messages.catalog.ts (결정 454 · apps 를 넘긴 호출자만). i18n 축을 쓰는 호출자만 준다.
44
+ */
45
+ regenerateMessages?(localesDir: string, out: string, baseLng?: string, apps?: readonly MessagesAppRef[]): unknown;
33
46
  /** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
34
47
  regenerateEnv?(envFile: string, out: string): unknown;
35
48
  watch(dir: string, opts: WatchOptions): WatchHandle;
@@ -60,8 +73,8 @@ export declare function startDev(deps: DevDeps): Promise<DevHandle>;
60
73
  export interface RegenDeps {
61
74
  regenerateTables(schemaDir: string, out: string): Promise<unknown>;
62
75
  regenerateRoutes(appDir: string, out: string): Promise<unknown>;
63
- /** locales/ → .gaon/messages.d.ts (결정 158 · W2 · 기준 로케일 = 결정 352). i18n 축을 쓰는 호출자만 준다. */
64
- regenerateMessages?(localesDir: string, out: string, baseLng?: string): unknown;
76
+ /** locales/ → .gaon/messages.d.ts + 앱별 messages.catalog.ts (결정 158 · 352 · 454). */
77
+ regenerateMessages?(localesDir: string, out: string, baseLng?: string, apps?: readonly MessagesAppRef[]): unknown;
65
78
  /** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
66
79
  regenerateEnv?(envFile: string, out: string): unknown;
67
80
  }
package/dist/dev.js CHANGED
@@ -40,7 +40,7 @@ export async function startDev(deps) {
40
40
  }
41
41
  // 결정 158(W2): locales/ → messages.d.ts.
42
42
  if (layout.localesDir && deps.regenerateMessages) {
43
- await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng);
43
+ await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout));
44
44
  deps.log({ kind: 'regen', target: 'messages' });
45
45
  }
46
46
  // 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts(VITE_* 타입 브리지). `.env` 부재는
@@ -80,7 +80,7 @@ export async function startDev(deps) {
80
80
  handles.push(deps.watch(localesDir, {
81
81
  filter: isMessagesChange,
82
82
  onChange: async () => {
83
- await regenerateMessages(localesDir, layout.messagesOut, layout.messagesBaseLng);
83
+ await regenerateMessages(localesDir, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout));
84
84
  deps.log({ kind: 'regen', target: 'messages' });
85
85
  },
86
86
  onError: deps.onError,
@@ -111,7 +111,8 @@ export async function regenerateGaonOnce(layout, deps) {
111
111
  // 결정 158(W2): locales/ 가 있으면 messages.d.ts 도 재생성한다(i18n 키 타입 브리지).
112
112
  let messages = false;
113
113
  if (layout.localesDir && deps.regenerateMessages) {
114
- messages = (await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng)) === true;
114
+ messages =
115
+ (await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout))) === true;
115
116
  }
116
117
  // 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts. `.env` 부재는 throw(수리 안내).
117
118
  let env = false;
@@ -120,6 +121,10 @@ export async function regenerateGaonOnce(layout, deps) {
120
121
  }
121
122
  return { tables: !!layout.schemaDir, apps: layout.apps.map((a) => a.name), messages, env };
122
123
  }
124
+ /** 결정 454: 레이아웃의 앱 목록을 메시지 축 입력(카탈로그 모듈 경로)으로 좁힌다. */
125
+ function messagesAppsOf(layout) {
126
+ return layout.apps.map((a) => ({ name: a.name, localesDir: a.localesDir, catalogOut: a.catalogOut }));
127
+ }
123
128
  /** cwd 관례로 프로젝트 레이아웃을 해석한다(존재하는 것만 포함). */
124
129
  export function resolveDevLayout(cwd) {
125
130
  const root = resolve(cwd);
@@ -142,6 +147,8 @@ export function resolveDevLayout(cwd) {
142
147
  name: entry.name,
143
148
  appDir,
144
149
  routesOut: join(appDir, '.gaon', 'routes.d.ts'),
150
+ localesDir: join(appDir, 'locales'),
151
+ catalogOut: join(appDir, '.gaon', 'messages.catalog.ts'),
145
152
  });
146
153
  }
147
154
  }
@@ -0,0 +1,7 @@
1
+ import type { DoctorCheck } from '../types.js';
2
+ import type { FixerPlan } from './types.js';
3
+ /**
4
+ * i18n-layout 위반(레거시 단일 파일)을 폴더 레이아웃으로 옮기는 계획을 만든다.
5
+ * 내용은 그대로다 — 키 이름도 바뀌지 않는다(결정 454 R4).
6
+ */
7
+ export declare function fixI18nLayout(issues: readonly DoctorCheck[], cwd: string): Promise<readonly FixerPlan[]>;
@@ -0,0 +1,40 @@
1
+ // @gaonjs/cli · doctor fixer · 레거시 단일 파일 카탈로그 → 폴더 레이아웃 이관 (결정 454)
2
+ //
3
+ // `locales/<로케일>.json` → `locales/<로케일>/backend.json` **이동만** 한다(rename 계획).
4
+ //
5
+ // **왜 전량 backend 인가(안전 방향 자동화)**: 자동 분류가 오판하면 서버 전용 문구를
6
+ // frontend 로 보내 클라 번들·CDN 캐시에 노출된다 — 되돌리기 어려운 방향의 사고다.
7
+ // 되돌리기 쉬운 방향(전량 backend = 클라에서 안 보임)으로만 자동화하고, 화면 문구를
8
+ // frontend.json 으로 옮기는 판단은 사람이 한다(doctor 경고가 그 다음 단계를 안내).
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ /**
12
+ * i18n-layout 위반(레거시 단일 파일)을 폴더 레이아웃으로 옮기는 계획을 만든다.
13
+ * 내용은 그대로다 — 키 이름도 바뀌지 않는다(결정 454 R4).
14
+ */
15
+ export async function fixI18nLayout(issues, cwd) {
16
+ const plans = [];
17
+ for (const issue of issues) {
18
+ const to = issue.detail?.to;
19
+ if (typeof to !== 'string' || issue.file === undefined)
20
+ continue;
21
+ let before;
22
+ try {
23
+ before = await readFile(join(cwd, issue.file), 'utf8');
24
+ }
25
+ catch {
26
+ continue; // 그 사이 파일이 사라졌으면 계획에서 제외(상위가 부분 적용을 감당하지 않게).
27
+ }
28
+ plans.push({
29
+ kind: 'rename',
30
+ file: issue.file,
31
+ to,
32
+ before,
33
+ summary: `카탈로그를 폴더 레이아웃으로 이동: ${issue.file} → ${to} (결정 454)\n` +
34
+ ` 내용·키는 그대로다. 이 상태에서는 **서버 전용**(클라로 나가지 않음)이다.\n` +
35
+ ` → 화면에 보이는 문구만 같은 폴더의 frontend.json 으로 옮긴 뒤 gaon gen 을 실행하세요.`,
36
+ refEdits: [], // JSON 카탈로그는 소스가 import 하지 않는다(경로 참조 갱신 없음).
37
+ });
38
+ }
39
+ return plans;
40
+ }
@@ -5,6 +5,7 @@ export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency
5
5
  export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
6
6
  export { fixModelFilename, rewriteModelImport } from './model-filename.js';
7
7
  export { fixSealClientWiring, wireSealClient } from './seal-client-wiring.js';
8
+ export { fixI18nLayout } from './i18n-layout.js';
8
9
  /**
9
10
  * 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
10
11
  * 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
@@ -10,10 +10,12 @@ import { fixDependencyDirection } from './dependency-direction.js';
10
10
  import { fixSchemaFilename } from './schema-filename.js';
11
11
  import { fixModelFilename } from './model-filename.js';
12
12
  import { fixSealClientWiring } from './seal-client-wiring.js';
13
+ import { fixI18nLayout } from './i18n-layout.js';
13
14
  export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency-direction.js';
14
15
  export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
15
16
  export { fixModelFilename, rewriteModelImport } from './model-filename.js';
16
17
  export { fixSealClientWiring, wireSealClient } from './seal-client-wiring.js';
18
+ export { fixI18nLayout } from './i18n-layout.js';
17
19
  /**
18
20
  * 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
19
21
  * 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
@@ -23,6 +25,7 @@ export const FIXERS = {
23
25
  'schema-filename': fixSchemaFilename,
24
26
  'model-filename': fixModelFilename,
25
27
  'seal-security': fixSealClientWiring,
28
+ 'i18n-layout': fixI18nLayout,
26
29
  };
27
30
  /**
28
31
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
@@ -35,6 +38,16 @@ export const FIXERS = {
35
38
  * (`fixers/capabilities.test.ts` · ALL_RULES ↔ 카탈로그 대칭).
36
39
  */
37
40
  export const FIXER_CAPABILITIES = [
41
+ {
42
+ rule: 'i18n-layout',
43
+ hasFixer: true,
44
+ note: '자동 · locales/<로케일>.json → locales/<로케일>/backend.json 이동(결정 454). 화면 문구를 frontend.json 으로 나누는 것은 노출 방향이라 사람이 판단한다.',
45
+ },
46
+ {
47
+ rule: 'i18n-app-scope',
48
+ hasFixer: false,
49
+ note: '수동 · 그 키를 이 앱 카탈로그(apps/<앱>/locales/<로케일>.json) 또는 공용 locales/<로케일>/frontend.json 에 추가(어느 앱 문구인지는 설계 판단).',
50
+ },
38
51
  {
39
52
  rule: 'response-mixing',
40
53
  hasFixer: false,
@@ -0,0 +1,8 @@
1
+ import type { RuleReport } from './types.js';
2
+ /**
3
+ * 주석을 공백으로 지운다(문자열 리터럴 안의 `//`·`/*` 는 보존). 스캐폴드·문서 주석이
4
+ * 예시로 적은 `t('키')` 를 실 호출로 오인해 **오탐 에러**를 내던 것을 막는다(브라우저 e2e
5
+ * 게이트가 실제로 잡았다). 길이를 보존해 라인 번호 계산이 어긋나지 않는다.
6
+ */
7
+ export declare function stripComments(src: string): string;
8
+ export declare function checkI18nAppScope(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,178 @@
1
+ // @gaonjs/cli · doctor · 앱 경계를 넘는 클라 번역 키 (결정 454 · 에러)
2
+ //
3
+ // **왜 doctor 가 필요한가(타입으로 못 잡는 축)**: 두 앱이 `GaonMessages.keys` 를 서로 다른
4
+ // 유니온으로 augment 하면 TS2717(Subsequent property declarations must have the same type)이
5
+ // 나고, `gaon check` 는 단일 tsc 프로그램이라 앱별 클라 키 유니온을 만들 수 없다(실측 · 결정 454).
6
+ // 그래서 클라 키 유니온은 **프로젝트 전체 frontend 합집합**이고, 앱 경계는 두 층이 강제한다:
7
+ // · 런타임 = 앱 청크에 그 앱 카탈로그만 실린다(물리적).
8
+ // · 정적 = 이 검사 — 앱 코드가 **다른 앱 전용 키**를 쓰면 에러로 잡는다.
9
+ //
10
+ // 그냥 두면 컴파일은 통과하고 화면에는 키 문자열이 그대로 뜬다(조용한 실패).
11
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
+ import { join, relative } from 'node:path';
13
+ import { flattenKeys, loadAppLocales, loadScopedLocales, withPluralBaseKeys } from '@gaonjs/i18n';
14
+ import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
15
+ // `import { t } from 'gaonjs/vue'` 를 쓴 파일에서만 본다 — 서버 t(gaonjs/i18n)는 대상이 아니다.
16
+ const CLIENT_T_IMPORT = /from\s+['"]gaonjs\/vue['"]/;
17
+ // t('key') · t("key") · t(`key`) 의 리터럴 키만 본다(동적 키는 정적 검출 대상 아님).
18
+ const T_CALL = /\bt\(\s*['"`]([^'"`]+)['"`]/g;
19
+ /**
20
+ * 주석을 공백으로 지운다(문자열 리터럴 안의 `//`·`/*` 는 보존). 스캐폴드·문서 주석이
21
+ * 예시로 적은 `t('키')` 를 실 호출로 오인해 **오탐 에러**를 내던 것을 막는다(브라우저 e2e
22
+ * 게이트가 실제로 잡았다). 길이를 보존해 라인 번호 계산이 어긋나지 않는다.
23
+ */
24
+ export function stripComments(src) {
25
+ const out = src.split('');
26
+ let i = 0;
27
+ let state = 'code';
28
+ const blank = (from, to) => {
29
+ for (let k = from; k < to; k++)
30
+ if (out[k] !== '\n')
31
+ out[k] = ' ';
32
+ };
33
+ while (i < src.length) {
34
+ const two = src.slice(i, i + 2);
35
+ if (state === 'code') {
36
+ if (two === '//') {
37
+ const end = src.indexOf('\n', i);
38
+ const stop = end === -1 ? src.length : end;
39
+ blank(i, stop);
40
+ i = stop;
41
+ continue;
42
+ }
43
+ if (two === '/*') {
44
+ const end = src.indexOf('*/', i + 2);
45
+ const stop = end === -1 ? src.length : end + 2;
46
+ blank(i, stop);
47
+ i = stop;
48
+ continue;
49
+ }
50
+ if (src.startsWith('<!--', i)) {
51
+ const end = src.indexOf('-->', i + 4);
52
+ const stop = end === -1 ? src.length : end + 3;
53
+ blank(i, stop);
54
+ i = stop;
55
+ continue;
56
+ }
57
+ const c = src[i];
58
+ if (c === "'")
59
+ state = 'single';
60
+ else if (c === '"')
61
+ state = 'double';
62
+ else if (c === '`')
63
+ state = 'tick';
64
+ i++;
65
+ continue;
66
+ }
67
+ // 문자열 안 — 이스케이프를 건너뛰고 닫는 따옴표에서 code 로 돌아간다.
68
+ const c = src[i];
69
+ if (c === '\\') {
70
+ i += 2;
71
+ continue;
72
+ }
73
+ if ((state === 'single' && c === "'") || (state === 'double' && c === '"') || (state === 'tick' && c === '`')) {
74
+ state = 'code';
75
+ }
76
+ i++;
77
+ }
78
+ return out.join('');
79
+ }
80
+ const SOURCE_EXT = ['.vue', '.ts'];
81
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.gaon']);
82
+ function walk(dir, out = []) {
83
+ if (!existsSync(dir))
84
+ return out;
85
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
86
+ if (entry.name.startsWith('.') && entry.name !== '.gaon')
87
+ continue;
88
+ if (SKIP_DIRS.has(entry.name))
89
+ continue;
90
+ const p = join(dir, entry.name);
91
+ if (entry.isDirectory())
92
+ walk(p, out);
93
+ else if (SOURCE_EXT.some((e) => entry.name.endsWith(e)))
94
+ out.push(p);
95
+ }
96
+ return out;
97
+ }
98
+ /** 그 앱의 클라가 실제로 받는 키 집합 = 공용 frontend ∪ 자기 앱 카탈로그(전 로케일 합집합). */
99
+ function keysForApp(localesDir, appLocalesDir) {
100
+ const scoped = loadScopedLocales(localesDir);
101
+ const keys = new Set();
102
+ for (const res of Object.values(scoped.frontend)) {
103
+ for (const k of withPluralBaseKeys(flattenKeys(res.translation)))
104
+ keys.add(k);
105
+ }
106
+ for (const res of Object.values(loadAppLocales(appLocalesDir))) {
107
+ for (const k of withPluralBaseKeys(flattenKeys(res.translation)))
108
+ keys.add(k);
109
+ }
110
+ return keys;
111
+ }
112
+ /** 프로젝트 전체(모든 앱)의 클라 키 — "다른 앱에는 있는 키" 인지 구분해 안내를 정확히 한다. */
113
+ function allClientKeys(localesDir, appDirs) {
114
+ const keys = keysForApp(localesDir, '');
115
+ for (const appDir of appDirs) {
116
+ for (const res of Object.values(loadAppLocales(join(appDir, 'locales')))) {
117
+ for (const k of withPluralBaseKeys(flattenKeys(res.translation)))
118
+ keys.add(k);
119
+ }
120
+ }
121
+ return keys;
122
+ }
123
+ export async function checkI18nAppScope(cwd) {
124
+ const appsDir = join(cwd, 'apps');
125
+ if (!existsSync(appsDir))
126
+ return { rule: 'i18n-app-scope', issues: [] };
127
+ const cfg = analyzeProjectI18n(cwd);
128
+ const localesDir = resolveLocalesDir(cwd, cfg.dir);
129
+ if (!existsSync(localesDir))
130
+ return { rule: 'i18n-app-scope', issues: [] };
131
+ const appDirs = readdirSync(appsDir, { withFileTypes: true })
132
+ .filter((e) => e.isDirectory())
133
+ .map((e) => join(appsDir, e.name));
134
+ // frontend 카탈로그가 하나도 없으면(레거시 레이아웃) 클라 t() 자체가 없다 — 무소음.
135
+ const scoped = loadScopedLocales(localesDir);
136
+ if (Object.keys(scoped.frontend).length === 0)
137
+ return { rule: 'i18n-app-scope', issues: [] };
138
+ const everyKey = allClientKeys(localesDir, appDirs);
139
+ const issues = [];
140
+ for (const appDir of appDirs) {
141
+ const appLocales = join(appDir, 'locales');
142
+ const own = keysForApp(localesDir, appLocales);
143
+ for (const file of walk(appDir)) {
144
+ if (!statSync(file).isFile())
145
+ continue;
146
+ const raw = readFileSync(file, 'utf8');
147
+ if (!CLIENT_T_IMPORT.test(raw))
148
+ continue;
149
+ // 주석 안 예시(`t('키')` 같은 설명)를 실 호출로 세지 않는다 — 오탐은 빌드를 세운다.
150
+ const src = stripComments(raw);
151
+ for (const m of src.matchAll(T_CALL)) {
152
+ const key = m[1];
153
+ if (own.has(key))
154
+ continue;
155
+ const line = src.slice(0, m.index).split('\n').length;
156
+ const rel = relative(cwd, file);
157
+ const elsewhere = everyKey.has(key);
158
+ issues.push({
159
+ rule: 'i18n-app-scope',
160
+ level: 'error',
161
+ file: rel,
162
+ line,
163
+ message: elsewhere
164
+ ? `'${key}' 는 **다른 앱 전용** 번역 키입니다 — 이 앱의 카탈로그에 없습니다: ${rel}:${line} (결정 454)\n` +
165
+ ` 앱 번들에는 그 앱 카탈로그만 실리므로 런타임에 번역 대신 키 문자열이 그대로 보입니다.\n` +
166
+ `→ 이 앱에서도 쓸 문구면 공용 ${relative(cwd, join(localesDir, '<로케일>', 'frontend.json'))} 로 옮기고,\n` +
167
+ `→ 이 앱 전용 문구면 ${relative(cwd, join(appLocales, '<로케일>.json'))} 에 추가한 뒤 gaon gen 을 실행하세요.`
168
+ : `'${key}' 를 어느 카탈로그에서도 찾을 수 없습니다: ${rel}:${line} (결정 454)\n` +
169
+ `→ ${relative(cwd, join(appLocales, '<로케일>.json'))}(앱 전용) 또는 ` +
170
+ `${relative(cwd, join(localesDir, '<로케일>', 'frontend.json'))}(앱 공용)에 키를 추가하고 gaon gen 을 실행하세요.\n` +
171
+ ` (서버 전용 문구는 backend.json 에 두고 서버 t()(gaonjs/i18n)로 쓰세요 — 클라로 나가지 않습니다.)`,
172
+ detail: { key, app: relative(cwd, appDir), definedInAnotherApp: elsewhere },
173
+ });
174
+ }
175
+ }
176
+ }
177
+ return { rule: 'i18n-app-scope', issues };
178
+ }
@@ -0,0 +1,4 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 레거시 단일 파일 로케일 목록(폴더 레이아웃과 공존하면 로더가 별도로 fail-loud). */
3
+ export declare function legacyLocaleFiles(localesDir: string): string[];
4
+ export declare function checkI18nLayout(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,47 @@
1
+ // @gaonjs/cli · doctor · i18n 카탈로그 레이아웃 안내 (결정 454 · 경고)
2
+ //
3
+ // 결정 454 의 정본 저작 레이아웃은 언어별 폴더 + 스코프 분리다:
4
+ // locales/<로케일>/backend.json — 메일·잡·검증 등 **서버 전용**(클라로 안 나감)
5
+ // locales/<로케일>/frontend.json — 화면 문구(클라 t() 가 쓰는 카탈로그)
6
+ //
7
+ // 레거시 단일 파일(`locales/<로케일>.json`)도 계속 **동작**하지만, 미분류 카탈로그는
8
+ // 안전 기본으로 전량 backend 취급이라 **클라 `t()` 를 쓸 수 없다**(클라 키 축 미생성).
9
+ // 그 사실을 조용히 두면 "왜 gaonjs/vue 의 t 가 키를 그대로 뱉지?" 로 시간을 태운다 —
10
+ // 경고로 짚고 `--fix` 로 폴더 이관까지 해 준다(노출 방향 자동 분류는 하지 않는다).
11
+ import { existsSync, readdirSync } from 'node:fs';
12
+ import { basename, join, relative } from 'node:path';
13
+ import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
14
+ /** 레거시 단일 파일 로케일 목록(폴더 레이아웃과 공존하면 로더가 별도로 fail-loud). */
15
+ export function legacyLocaleFiles(localesDir) {
16
+ if (!existsSync(localesDir))
17
+ return [];
18
+ return readdirSync(localesDir, { withFileTypes: true })
19
+ .filter((e) => e.isFile() && e.name.endsWith('.json'))
20
+ .map((e) => basename(e.name, '.json'))
21
+ .sort();
22
+ }
23
+ export async function checkI18nLayout(cwd) {
24
+ const cfg = analyzeProjectI18n(cwd);
25
+ const localesDir = resolveLocalesDir(cwd, cfg.dir);
26
+ if (!existsSync(localesDir))
27
+ return { rule: 'i18n-layout', issues: [] };
28
+ const legacy = legacyLocaleFiles(localesDir);
29
+ if (legacy.length === 0)
30
+ return { rule: 'i18n-layout', issues: [] };
31
+ const issues = legacy.map((lng) => {
32
+ const rel = relative(cwd, join(localesDir, `${lng}.json`));
33
+ const dirRel = relative(cwd, join(localesDir, lng));
34
+ return {
35
+ rule: 'i18n-layout',
36
+ level: 'warning',
37
+ file: rel,
38
+ message: `로케일 '${lng}' 이 단일 파일 레이아웃입니다: ${rel} (결정 454)\n` +
39
+ ` 이 카탈로그는 **서버 전용**으로 취급돼 클라이언트 t()(gaonjs/vue)가 쓸 수 없습니다.\n` +
40
+ `→ 자동 이관: gaon doctor --fix (${rel} → ${dirRel}/backend.json)\n` +
41
+ `→ 그 뒤 화면에 보이는 문구만 ${dirRel}/frontend.json 으로 옮기고 gaon gen 을 실행하세요.\n` +
42
+ ` (frontend 만 클라 번들 청크로 나갑니다 — backend 문구는 노출되지 않습니다.)`,
43
+ detail: { locale: lng, from: rel, to: `${dirRel}/backend.json` },
44
+ };
45
+ });
46
+ return { rule: 'i18n-layout', issues };
47
+ }
@@ -10,7 +10,7 @@
10
10
  // 빌드를 세우지 않는다. --json 은 detail.locale·detail.missing 으로 구조화한다.
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join, relative } from 'node:path';
13
- import { loadLocales, flattenKeys } from '@gaonjs/i18n';
13
+ import { loadScopedLocales, flattenKeys } from '@gaonjs/i18n';
14
14
  import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
15
15
  // 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
16
16
  const MAX_KEYS_SHOWN = 20;
@@ -32,10 +32,22 @@ export async function checkLocaleParity(cwd) {
32
32
  const localesDir = resolveLocalesDir(cwd, analyzeProjectI18n(cwd).dir);
33
33
  if (!existsSync(localesDir))
34
34
  return { rule: 'locale-parity', issues: [] };
35
- const resources = loadLocales(localesDir);
35
+ // 결정 454: 스코프(backend·frontend)별로 따로 비교한다 — 같은 키를 로케일마다 다른
36
+ // 스코프에 두면(ko 는 frontend, ja 는 backend) 클라 카탈로그가 갈리므로, 병합본으로
37
+ // 비교하면 그 배치 불일치를 못 본다. 파일 경로도 스코프별로 정확히 짚는다.
38
+ const scoped = loadScopedLocales(localesDir);
39
+ const issues = [];
40
+ for (const scope of ['backend', 'frontend']) {
41
+ const resources = scoped[scope];
42
+ issues.push(...parityIssues(cwd, localesDir, resources, scoped.layout, scope));
43
+ }
44
+ return { rule: 'locale-parity', issues };
45
+ }
46
+ /** 한 스코프 안에서 로케일 간 키 diff 를 계산한다(레거시 단일 파일은 backend 스코프에 담긴다). */
47
+ function parityIssues(cwd, localesDir, resources, layout, scope) {
36
48
  const langs = Object.keys(resources).sort();
37
49
  if (langs.length < 2)
38
- return { rule: 'locale-parity', issues: [] };
50
+ return [];
39
51
  // 로케일별 키 집합과 전체 union(어느 로케일에든 등장한 키의 합집합)을 만든다.
40
52
  // 복수형 접미사는 base 로 정규화해 로케일별 정당한 복수형 차이를 오탐으로 잡지 않는다.
41
53
  const keysByLang = new Map();
@@ -52,7 +64,10 @@ export async function checkLocaleParity(cwd) {
52
64
  const missing = [...union].filter((k) => !have.has(k)).sort();
53
65
  if (missing.length === 0)
54
66
  continue;
55
- const rel = relative(cwd, join(localesDir, `${lng}.json`));
67
+ // 파일 경로는 레이아웃에 맞춘다 — 폴더 분리면 <lng>/<scope>.json, 레거시면 <lng>.json.
68
+ const rel = layout[lng] === 'legacy'
69
+ ? relative(cwd, join(localesDir, `${lng}.json`))
70
+ : relative(cwd, join(localesDir, lng, `${scope}.json`));
56
71
  const shown = missing.slice(0, MAX_KEYS_SHOWN);
57
72
  const more = missing.length - shown.length;
58
73
  const list = shown.map((k) => `'${k}'`).join(', ') + (more > 0 ? ` …외 ${more}개` : '');
@@ -64,8 +79,8 @@ export async function checkLocaleParity(cwd) {
64
79
  ` 누락 키: ${list}\n` +
65
80
  `→ ${rel} 에 이 키들을 채우세요 — 없으면 '${lng}' 사용자에게 fallback(대개 다른 언어)\n` +
66
81
  ` 번역이 조용히 노출됩니다. messages.d.ts 는 기준 로케일 기준이라 컴파일로 못 잡습니다(결정 216).`,
67
- detail: { locale: lng, missing },
82
+ detail: { locale: lng, scope, missing },
68
83
  });
69
84
  }
70
- return { rule: 'locale-parity', issues };
85
+ return issues;
71
86
  }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | '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' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | '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' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch' | 'i18n-layout' | 'i18n-app-scope';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -25,6 +25,8 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
25
25
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
26
26
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
27
27
  export { checkLocaleParity } from './doctor/locale-parity.js';
28
+ export { checkI18nLayout } from './doctor/i18n-layout.js';
29
+ export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
28
30
  export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
29
31
  export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
30
32
  export { renderHuman, renderJson } from './doctor/reporter.js';
@@ -33,7 +35,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
33
35
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
34
36
  */
35
37
  /**
36
- * doctor 정적 검사 33종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
38
+ * doctor 정적 검사 35종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
37
39
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
38
40
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
39
41
  */
package/dist/doctor.js CHANGED
@@ -30,6 +30,8 @@
30
30
  * 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
31
31
  * 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
32
32
  * 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
33
+ * 34) i18n-layout (결정 454 · 레거시 단일 파일 = 클라 t() 불가 안내 · --fix 로 폴더 이관)
34
+ * 35) i18n-app-scope (결정 454 · 앱 카탈로그에 없는 클라 t() 키 = 런타임 키 노출)
33
35
  * 28) render-return (결정 340 · this.render/redirect/json 호출만 하고 return 누락 = 무신호 204 경고)
34
36
  * 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
35
37
  * 30) channel-instance-authorize (결정 440 · authorize 없는 인스턴스 채널 = 임의 인스턴스 공개 입장 경고)
@@ -76,6 +78,8 @@ import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
76
78
  import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
77
79
  import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
78
80
  import { checkLocaleParity } from './doctor/locale-parity.js';
81
+ import { checkI18nLayout } from './doctor/i18n-layout.js';
82
+ import { checkI18nAppScope } from './doctor/i18n-app-scope.js';
79
83
  import { checkChannelCollision } from './doctor/channel-collision.js';
80
84
  import { checkChannelInstanceAuthorize } from './doctor/channel-instance-authorize.js';
81
85
  import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
@@ -108,6 +112,8 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
108
112
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
109
113
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
110
114
  export { checkLocaleParity } from './doctor/locale-parity.js';
115
+ export { checkI18nLayout } from './doctor/i18n-layout.js';
116
+ export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
111
117
  export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
112
118
  export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
113
119
  export { renderHuman, renderJson } from './doctor/reporter.js';
@@ -116,7 +122,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
116
122
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
117
123
  */
118
124
  /**
119
- * doctor 정적 검사 33종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
125
+ * doctor 정적 검사 35종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
120
126
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
121
127
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
122
128
  */
@@ -154,6 +160,8 @@ export const ALL_RULES = [
154
160
  'channel-instance-authorize',
155
161
  'dotenv-node-env',
156
162
  'page-fetch',
163
+ 'i18n-layout',
164
+ 'i18n-app-scope',
157
165
  ];
158
166
  /**
159
167
  * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
@@ -190,6 +198,8 @@ export const RULE_SUMMARIES = {
190
198
  'schema-relations': '§4.5 관계',
191
199
  'no-import-meta-env': 'import.meta.env',
192
200
  'locale-parity': '로케일 커버리지',
201
+ 'i18n-layout': 'i18n 카탈로그 레이아웃(단일 파일 → backend/frontend 분리 안내 · 결정 454)',
202
+ 'i18n-app-scope': '앱 경계를 넘는 클라 번역 키(그 앱 카탈로그에 없는 t() 키 · 결정 454)',
193
203
  'render-return': 'render return 누락',
194
204
  'channel-collision': '앱간 동명 채널',
195
205
  'channel-instance-authorize': '인스턴스 채널 authorize',
@@ -225,6 +235,8 @@ const CHECKERS = {
225
235
  'schema-relations': checkSchemaRelations,
226
236
  'no-import-meta-env': checkNoImportMetaEnv,
227
237
  'locale-parity': checkLocaleParity,
238
+ 'i18n-layout': checkI18nLayout,
239
+ 'i18n-app-scope': checkI18nAppScope,
228
240
  'render-return': checkRenderReturn,
229
241
  'channel-collision': checkChannelCollision,
230
242
  'channel-instance-authorize': checkChannelInstanceAuthorize,
package/dist/index.js CHANGED
@@ -156,7 +156,7 @@ function renderHelp(version = VERSION) {
156
156
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
157
157
  */
158
158
  export function parseDoctorChecks(argv) {
159
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 33종)를 단일 출처로 쓴다 — 과거
159
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 35종)를 단일 출처로 쓴다 — 과거
160
160
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
161
161
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
162
162
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -177,7 +177,7 @@ export function parseDoctorChecks(argv) {
177
177
  }
178
178
  // 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
179
179
  // 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
180
- // 33종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
180
+ // 35종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
181
181
  if (unknown.length > 0) {
182
182
  process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
183
183
  ` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +