@gaonjs/cli 0.63.1 → 0.65.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.
- package/dist/commands/check.js +34 -18
- package/dist/commands/g.d.ts +1 -0
- package/dist/commands/g.js +11 -2
- package/dist/commands/gen.js +1 -1
- package/dist/dev.d.ts +13 -7
- package/dist/dev.js +30 -22
- package/dist/doctor/fixers/i18n-layout.d.ts +2 -2
- package/dist/doctor/fixers/i18n-layout.js +119 -25
- package/dist/doctor/fixers/index.js +7 -2
- package/dist/doctor/fixers/types.d.ts +17 -2
- package/dist/doctor/i18n-app-scope.d.ts +3 -0
- package/dist/doctor/i18n-app-scope.js +94 -65
- package/dist/doctor/i18n-layout.d.ts +1 -1
- package/dist/doctor/i18n-layout.js +157 -32
- package/dist/doctor/i18n-server-scope.d.ts +2 -0
- package/dist/doctor/i18n-server-scope.js +124 -0
- package/dist/doctor/locale-parity.js +29 -68
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +36 -10
- package/dist/i18n-config.d.ts +9 -9
- package/dist/i18n-config.js +8 -15
- package/dist/messages-gen.d.ts +10 -10
- package/dist/messages-gen.js +42 -54
- package/dist/scaffold/app.d.ts +10 -1
- package/dist/scaffold/app.js +12 -2
- package/dist/templates/project/AGENTS.md.tpl +7 -6
- package/dist/templates/project/agents/async.md.tpl +9 -9
- package/dist/templates/project/agents/data.md.tpl +37 -12
- package/dist/templates/project/agents/frontend.md.tpl +14 -9
- package/dist/templates/project/agents/i18n.md.tpl +185 -74
- package/dist/templates/project/agents/mail.md.tpl +3 -1
- package/dist/templates/project/agents/realtime.md.tpl +54 -31
- package/dist/templates/project/agents/seal.md.tpl +22 -4
- package/dist/templates/project/agents/security.md.tpl +1 -1
- package/dist/templates/project/agents/storage.md.tpl +23 -15
- package/dist/templates/project/agents/testing.md.tpl +5 -5
- package/dist/templates/project/agents/web.md.tpl +24 -11
- package/dist/templates/project/{locales → apps/web/locales}/en/frontend.json.tpl +1 -0
- package/dist/templates/project/{locales → apps/web/locales}/ko/frontend.json.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +6 -5
- package/package.json +7 -7
- package/dist/templates/project/apps/web/locales/en.json.tpl +0 -3
- package/dist/templates/project/apps/web/locales/ko.json.tpl +0 -3
- /package/dist/templates/project/{locales → domain/locales}/en/backend.json.tpl +0 -0
- /package/dist/templates/project/{locales → domain/locales}/ko/backend.json.tpl +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @gaonjs/cli · doctor · 앱 경계를 넘는 클라 번역 키 (결정 454 · 에러)
|
|
1
|
+
// @gaonjs/cli · doctor · 앱 경계를 넘는 클라 번역 키 (결정 454 · 459 · 에러)
|
|
2
2
|
//
|
|
3
3
|
// **왜 doctor 가 필요한가(타입으로 못 잡는 축)**: 두 앱이 `GaonMessages.keys` 를 서로 다른
|
|
4
4
|
// 유니온으로 augment 하면 TS2717(Subsequent property declarations must have the same type)이
|
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
// · 정적 = 이 검사 — 앱 코드가 **다른 앱 전용 키**를 쓰면 에러로 잡는다.
|
|
9
9
|
//
|
|
10
10
|
// 그냥 두면 컴파일은 통과하고 화면에는 키 문자열이 그대로 뜬다(조용한 실패).
|
|
11
|
+
//
|
|
12
|
+
// 결정 459 로 두 가지가 바뀐다:
|
|
13
|
+
// · 공용 frontend 스코프가 폐지돼 그 앱이 보는 키 = **자기 앱 frontend 뿐**이다.
|
|
14
|
+
// · `shared/**` 컴포넌트의 t() 키는 **모든 앱**에 있어야 한다(O1) — shared 는 어느 앱
|
|
15
|
+
// 번들에도 실릴 수 있는데 키가 한 앱에만 있으면 다른 앱 화면에서만 키가 뜬다.
|
|
16
|
+
// 문구를 props 로 끌어올리는 대안(O1 (b))은 원자 컴포넌트마다 label prop 이 생겨
|
|
17
|
+
// 결정 454 가 없앤 "선언 지점 3곳" 통증을 되살리므로 기각했다 — 복제 + 이 검사다.
|
|
11
18
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
12
19
|
import { join, relative } from 'node:path';
|
|
13
|
-
import { flattenKeys,
|
|
14
|
-
import { analyzeProjectI18n
|
|
20
|
+
import { flattenKeys, loadProjectLocales, withPluralBaseKeys } from '@gaonjs/i18n';
|
|
21
|
+
import { analyzeProjectI18n } from '../i18n-config.js';
|
|
15
22
|
// `import { t } from 'gaonjs/vue'` 를 쓴 파일에서만 본다 — 서버 t(gaonjs/i18n)는 대상이 아니다.
|
|
16
23
|
const CLIENT_T_IMPORT = /from\s+['"]gaonjs\/vue['"]/;
|
|
17
24
|
// t('key') · t("key") · t(`key`) 의 리터럴 키만 본다(동적 키는 정적 검출 대상 아님).
|
|
18
|
-
|
|
25
|
+
// i18n-server-scope(결정 459)가 같은 규약으로 서버 t() 를 훑는다 — 한 벌만 둔다.
|
|
26
|
+
export const T_CALL = /\bt\(\s*['"`]([^'"`]+)['"`]/g;
|
|
19
27
|
/**
|
|
20
28
|
* 주석을 공백으로 지운다(문자열 리터럴 안의 `//`·`/*` 는 보존). 스캐폴드·문서 주석이
|
|
21
29
|
* 예시로 적은 `t('키')` 를 실 호출로 오인해 **오탐 에러**를 내던 것을 막는다(브라우저 e2e
|
|
@@ -79,7 +87,8 @@ export function stripComments(src) {
|
|
|
79
87
|
}
|
|
80
88
|
const SOURCE_EXT = ['.vue', '.ts'];
|
|
81
89
|
const SKIP_DIRS = new Set(['node_modules', 'dist', '.gaon']);
|
|
82
|
-
|
|
90
|
+
/** 소스 파일을 재귀 수집한다(생성물·의존성 제외). i18n-server-scope 와 공유. */
|
|
91
|
+
export function walkSources(dir, exts = SOURCE_EXT, out = []) {
|
|
83
92
|
if (!existsSync(dir))
|
|
84
93
|
return out;
|
|
85
94
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -89,90 +98,110 @@ function walk(dir, out = []) {
|
|
|
89
98
|
continue;
|
|
90
99
|
const p = join(dir, entry.name);
|
|
91
100
|
if (entry.isDirectory())
|
|
92
|
-
|
|
93
|
-
else if (
|
|
101
|
+
walkSources(p, exts, out);
|
|
102
|
+
else if (exts.some((e) => entry.name.endsWith(e)))
|
|
94
103
|
out.push(p);
|
|
95
104
|
}
|
|
96
105
|
return out;
|
|
97
106
|
}
|
|
98
|
-
/**
|
|
99
|
-
function
|
|
100
|
-
const scoped = loadScopedLocales(localesDir);
|
|
107
|
+
/** 카탈로그의 전 로케일 키 합집합(복수형 base 포함). */
|
|
108
|
+
function keysOf(res) {
|
|
101
109
|
const keys = new Set();
|
|
102
|
-
for (const
|
|
103
|
-
for (const k of withPluralBaseKeys(flattenKeys(
|
|
104
|
-
keys.add(k);
|
|
105
|
-
}
|
|
106
|
-
for (const res of Object.values(loadAppLocales(appLocalesDir))) {
|
|
107
|
-
for (const k of withPluralBaseKeys(flattenKeys(res.translation)))
|
|
110
|
+
for (const entry of Object.values(res)) {
|
|
111
|
+
for (const k of withPluralBaseKeys(flattenKeys(entry.translation)))
|
|
108
112
|
keys.add(k);
|
|
109
113
|
}
|
|
110
114
|
return keys;
|
|
111
115
|
}
|
|
112
|
-
/**
|
|
113
|
-
function
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
/** 파일에서 클라 t() 리터럴 키를 뽑는다(주석 안 예시는 제외 · 줄 번호 포함). */
|
|
117
|
+
function clientKeyUses(file) {
|
|
118
|
+
const raw = readFileSync(file, 'utf8');
|
|
119
|
+
if (!CLIENT_T_IMPORT.test(raw))
|
|
120
|
+
return [];
|
|
121
|
+
// 주석 안 예시(`t('키')` 같은 설명)를 실 호출로 세지 않는다 — 오탐은 빌드를 세운다.
|
|
122
|
+
const src = stripComments(raw);
|
|
123
|
+
return [...src.matchAll(T_CALL)].map((m) => ({
|
|
124
|
+
key: m[1],
|
|
125
|
+
line: src.slice(0, m.index).split('\n').length,
|
|
126
|
+
}));
|
|
122
127
|
}
|
|
123
128
|
export async function checkI18nAppScope(cwd) {
|
|
124
|
-
const
|
|
125
|
-
if (!existsSync(
|
|
126
|
-
return { rule
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
129
|
+
const rule = 'i18n-app-scope';
|
|
130
|
+
if (!existsSync(join(cwd, 'apps')))
|
|
131
|
+
return { rule, issues: [] };
|
|
132
|
+
if (!analyzeProjectI18n(cwd).declared)
|
|
133
|
+
return { rule, issues: [] };
|
|
134
|
+
let project;
|
|
135
|
+
try {
|
|
136
|
+
project = loadProjectLocales(cwd);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// 로더 fail-loud 는 i18n-layout·check 가 소유한다 — 여기선 침묵(중복 진단 금지).
|
|
140
|
+
return { rule, issues: [] };
|
|
141
|
+
}
|
|
142
|
+
const frontendByApp = new Map();
|
|
143
|
+
for (const [app, scoped] of Object.entries(project.apps))
|
|
144
|
+
frontendByApp.set(app, keysOf(scoped.frontend));
|
|
145
|
+
// frontend 카탈로그가 하나도 없으면 클라 t() 축 자체가 없다 — 무소음.
|
|
146
|
+
if ([...frontendByApp.values()].every((s) => s.size === 0))
|
|
147
|
+
return { rule, issues: [] };
|
|
148
|
+
const everyKey = new Set();
|
|
149
|
+
for (const keys of frontendByApp.values())
|
|
150
|
+
for (const k of keys)
|
|
151
|
+
everyKey.add(k);
|
|
139
152
|
const issues = [];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
for (const file of
|
|
153
|
+
// ① 앱 코드 — 그 앱 frontend 에 있어야 한다(결정 459: 공용 스코프가 없어 폴백이 1단).
|
|
154
|
+
for (const [app, own] of frontendByApp) {
|
|
155
|
+
const appDir = join(cwd, 'apps', app);
|
|
156
|
+
for (const file of walkSources(appDir)) {
|
|
144
157
|
if (!statSync(file).isFile())
|
|
145
158
|
continue;
|
|
146
|
-
const
|
|
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];
|
|
159
|
+
for (const { key, line } of clientKeyUses(file)) {
|
|
153
160
|
if (own.has(key))
|
|
154
161
|
continue;
|
|
155
|
-
const line = src.slice(0, m.index).split('\n').length;
|
|
156
162
|
const rel = relative(cwd, file);
|
|
157
163
|
const elsewhere = everyKey.has(key);
|
|
158
164
|
issues.push({
|
|
159
|
-
rule
|
|
165
|
+
rule,
|
|
160
166
|
level: 'error',
|
|
161
167
|
file: rel,
|
|
162
168
|
line,
|
|
163
|
-
message: elsewhere
|
|
164
|
-
? `'${key}' 는 **다른 앱 전용** 번역 키입니다 — 이 앱의 카탈로그에 없습니다: ${rel}:${line} (결정
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
` (서버 전용 문구는 backend.json 에 두고 서버 t()(gaonjs/i18n)로 쓰세요 — 클라로 나가지 않습니다.)`,
|
|
172
|
-
detail: { key, app: relative(cwd, appDir), definedInAnotherApp: elsewhere },
|
|
169
|
+
message: (elsewhere
|
|
170
|
+
? `'${key}' 는 **다른 앱 전용** 번역 키입니다 — 이 앱의 카탈로그에 없습니다: ${rel}:${line} (결정 459)\n`
|
|
171
|
+
: `'${key}' 를 어느 앱 카탈로그에서도 찾을 수 없습니다: ${rel}:${line} (결정 459)\n`) +
|
|
172
|
+
` 앱 번들에는 그 앱 카탈로그만 실리므로 런타임에 번역 대신 키 문자열이 그대로 보입니다.\n` +
|
|
173
|
+
`→ apps/${app}/locales/<로케일>/frontend.json 에 이 키를 추가하고 gaon gen 을 실행하세요.\n` +
|
|
174
|
+
` (여러 앱이 함께 쓰는 문구면 각 앱의 frontend.json 에 같은 키를 둡니다 — 공용 카탈로그는 없습니다.)\n` +
|
|
175
|
+
` (서버 전용 문구는 backend.json 에 두고 서버 t()(gaonjs/i18n)로 쓰세요 — 클라로 나가지 않습니다.)`,
|
|
176
|
+
detail: { key, app, definedInAnotherApp: elsewhere },
|
|
173
177
|
});
|
|
174
178
|
}
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
|
-
|
|
181
|
+
// ② shared 코드 — **모든 앱**에 있어야 한다(결정 459 O1). shared 컴포넌트는 어느 앱
|
|
182
|
+
// 번들에도 실릴 수 있어, 키가 한 앱에만 있으면 다른 앱 화면에서만 조용히 키가 뜬다.
|
|
183
|
+
const allApps = [...frontendByApp.keys()].sort();
|
|
184
|
+
for (const file of walkSources(join(cwd, 'shared'))) {
|
|
185
|
+
if (!statSync(file).isFile())
|
|
186
|
+
continue;
|
|
187
|
+
for (const { key, line } of clientKeyUses(file)) {
|
|
188
|
+
const missing = allApps.filter((a) => !frontendByApp.get(a).has(key));
|
|
189
|
+
if (missing.length === 0)
|
|
190
|
+
continue;
|
|
191
|
+
const rel = relative(cwd, file);
|
|
192
|
+
issues.push({
|
|
193
|
+
rule,
|
|
194
|
+
level: 'error',
|
|
195
|
+
file: rel,
|
|
196
|
+
line,
|
|
197
|
+
message: `shared 컴포넌트가 쓰는 '${key}' 가 일부 앱 카탈로그에 없습니다(누락: ${missing.join(', ')}): ${rel}:${line} (결정 459)\n` +
|
|
198
|
+
` shared/ 는 어느 앱 번들에도 실릴 수 있으므로, 그 키는 **모든 앱**에 있어야 합니다.\n` +
|
|
199
|
+
` 한 앱에만 있으면 다른 앱 화면에서만 키 문자열이 뜨는 조용한 실패가 됩니다.\n` +
|
|
200
|
+
`→ ${missing.map((a) => `apps/${a}/locales/<로케일>/frontend.json`).join(' · ')} 에 같은 키를 추가하고 gaon gen 을 실행하세요.\n` +
|
|
201
|
+
` (공용 카탈로그는 없습니다 — UI 원자 문구는 앱마다 복제하고 이 검사가 전 앱 존재를 강제합니다.)`,
|
|
202
|
+
detail: { key, scope: 'shared', missingApps: missing },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { rule, issues };
|
|
178
207
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { RuleReport } from './types.js';
|
|
2
|
-
/** 레거시
|
|
2
|
+
/** 폴더의 레거시 평면 로케일 파일 목록(`<로케일>.json`). */
|
|
3
3
|
export declare function legacyLocaleFiles(localesDir: string): string[];
|
|
4
4
|
export declare function checkI18nLayout(cwd: string): Promise<RuleReport>;
|
|
@@ -1,17 +1,25 @@
|
|
|
1
|
-
// @gaonjs/cli · doctor · i18n 카탈로그 레이아웃 안내 (결정 454 ·
|
|
1
|
+
// @gaonjs/cli · doctor · i18n 카탈로그 레이아웃 이관 안내 (결정 454 · 459)
|
|
2
2
|
//
|
|
3
|
-
// 결정
|
|
4
|
-
// locales/<로케일>/backend.json
|
|
5
|
-
// locales/<로케일>/frontend.json
|
|
3
|
+
// 결정 459 의 정본 저작 레이아웃은 **소유자 2개 × 스코프 파일**이다:
|
|
4
|
+
// domain/locales/<로케일>/backend.json 도메인 공통 서버 문구(메일·잡·검증)
|
|
5
|
+
// apps/<앱>/locales/<로케일>/frontend.json 그 앱 화면 문구(필수)
|
|
6
|
+
// apps/<앱>/locales/<로케일>/backend.json 그 앱 컨트롤러 서버 문구(선택)
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// 이 검사는 **옛 배치에서 새 배치로 가는 길**을 지목한다. 결정 459 O4 대로 전이 기간
|
|
9
|
+
// (듀얼 리드)은 두지 않는다 — 한 릴리스 동안 "정답이 둘" 을 공식화하고 그 코드가 잔재로
|
|
10
|
+
// 남는다. 대신 **조용한 degrade 를 금지**한다: 옛 배치를 그냥 두면 아무도 읽지 않는
|
|
11
|
+
// 폴더가 되어 전 화면·전 메일이 키 문자열로 degrade 하므로(결정 455 는 그걸 우아하게
|
|
12
|
+
// 만들 뿐 대량 누락은 사고다), 여기서 warning/error 로 짚고 `--fix` 로 옮겨 준다.
|
|
13
|
+
//
|
|
14
|
+
// 검출 4종:
|
|
15
|
+
// ① 루트 `locales/` — 폐지된 공용 스코프(warning · --fix 로 이관)
|
|
16
|
+
// ② 앱 평면 `<로케일>.json` — 스코프 폴더 이전 배치(warning · --fix 로 rename)
|
|
17
|
+
// ③ `domain/locales/<로케일>/frontend.json` — 도메인은 화면을 소유하지 않는다(error)
|
|
18
|
+
// ④ `gaon.config.ts` 의 `i18n.dir` — 제거된 설정(error · O3)
|
|
11
19
|
import { existsSync, readdirSync } from 'node:fs';
|
|
12
20
|
import { basename, join, relative } from 'node:path';
|
|
13
|
-
import { analyzeProjectI18n
|
|
14
|
-
/** 레거시
|
|
21
|
+
import { analyzeProjectI18n } from '../i18n-config.js';
|
|
22
|
+
/** 폴더의 레거시 평면 로케일 파일 목록(`<로케일>.json`). */
|
|
15
23
|
export function legacyLocaleFiles(localesDir) {
|
|
16
24
|
if (!existsSync(localesDir))
|
|
17
25
|
return [];
|
|
@@ -20,28 +28,145 @@ export function legacyLocaleFiles(localesDir) {
|
|
|
20
28
|
.map((e) => basename(e.name, '.json'))
|
|
21
29
|
.sort();
|
|
22
30
|
}
|
|
31
|
+
/** 폴더의 언어 폴더 목록(`<로케일>/`). */
|
|
32
|
+
function localeDirs(dir) {
|
|
33
|
+
if (!existsSync(dir))
|
|
34
|
+
return [];
|
|
35
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
36
|
+
.filter((e) => e.isDirectory())
|
|
37
|
+
.map((e) => e.name)
|
|
38
|
+
.sort();
|
|
39
|
+
}
|
|
40
|
+
/** `apps/` 아래 앱 폴더 이름들(없으면 빈 배열). */
|
|
41
|
+
function appNames(cwd) {
|
|
42
|
+
try {
|
|
43
|
+
return readdirSync(join(cwd, 'apps'), { withFileTypes: true })
|
|
44
|
+
.filter((e) => e.isDirectory())
|
|
45
|
+
.map((e) => e.name)
|
|
46
|
+
.sort();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
23
52
|
export async function checkI18nLayout(cwd) {
|
|
53
|
+
const rule = 'i18n-layout';
|
|
54
|
+
const issues = [];
|
|
55
|
+
const apps = appNames(cwd);
|
|
56
|
+
// ① 루트 locales/ — 폐지된 공용 스코프. backend 는 도메인으로, frontend 는 전 앱으로.
|
|
57
|
+
const rootLocales = join(cwd, 'locales');
|
|
58
|
+
if (existsSync(rootLocales)) {
|
|
59
|
+
for (const lng of localeDirs(rootLocales)) {
|
|
60
|
+
const backend = join(rootLocales, lng, 'backend.json');
|
|
61
|
+
const frontend = join(rootLocales, lng, 'frontend.json');
|
|
62
|
+
if (existsSync(backend)) {
|
|
63
|
+
const from = relative(cwd, backend);
|
|
64
|
+
const to = `domain/locales/${lng}/backend.json`;
|
|
65
|
+
issues.push({
|
|
66
|
+
rule,
|
|
67
|
+
level: 'warning',
|
|
68
|
+
file: from,
|
|
69
|
+
message: `루트 공용 카탈로그는 폐지됐습니다: ${from} (결정 459)\n` +
|
|
70
|
+
` 이 파일은 더 이상 읽히지 않습니다 — 그대로 두면 메일·잡·검증 문구가 전부\n` +
|
|
71
|
+
` 키 문자열로 degrade 합니다.\n` +
|
|
72
|
+
`→ 자동 이관: gaon doctor --fix (${from} → ${to})\n` +
|
|
73
|
+
` (도메인 공통 서버 문구의 새 정본 위치입니다. 내용·키는 그대로입니다.)`,
|
|
74
|
+
detail: { locale: lng, kind: 'root-backend', from, to },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (existsSync(frontend)) {
|
|
78
|
+
const from = relative(cwd, frontend);
|
|
79
|
+
// 화면 문구는 소유자가 앱이다 — 전 앱에 **복제**한다(어느 앱이 실제로 쓰는지는
|
|
80
|
+
// 정적으로 판정할 수 없다: 동적 키가 섞이면 오판이 화면 누락으로 나타난다).
|
|
81
|
+
// 되돌리기 쉬운 방향(복제 후 사람이 잉여 정리)만 자동화한다.
|
|
82
|
+
const to = apps.map((a) => `apps/${a}/locales/${lng}/frontend.json`);
|
|
83
|
+
issues.push({
|
|
84
|
+
rule,
|
|
85
|
+
level: 'warning',
|
|
86
|
+
file: from,
|
|
87
|
+
message: `루트 공용 카탈로그는 폐지됐습니다: ${from} (결정 459)\n` +
|
|
88
|
+
` 화면 문구의 소유자는 **그 앱**입니다 — 이 파일은 더 이상 어느 번들에도 실리지 않습니다.\n` +
|
|
89
|
+
(apps.length > 0
|
|
90
|
+
? `→ 자동 이관: gaon doctor --fix (${to.join(' · ')} 로 **복제** · 기존 키와 병합)\n` +
|
|
91
|
+
` 그 뒤 각 앱이 실제로 쓰지 않는 키는 사람이 지웁니다(doctor 가 남은 것을 계속 안내합니다).\n` +
|
|
92
|
+
` shared/ 컴포넌트가 쓰는 키는 **모든 앱에 있어야** 합니다 — i18n-app-scope 가 강제합니다.`
|
|
93
|
+
: `→ apps/ 아래 앱이 없습니다. 앱을 만든 뒤(gaon g app) 다시 실행하세요.`),
|
|
94
|
+
detail: { locale: lng, kind: 'root-frontend', from, to },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 루트 레거시 단일 파일(`locales/ko.json`) — 미분류 = 서버 전용 취급이 안전 기본.
|
|
99
|
+
for (const lng of legacyLocaleFiles(rootLocales)) {
|
|
100
|
+
const from = relative(cwd, join(rootLocales, `${lng}.json`));
|
|
101
|
+
const to = `domain/locales/${lng}/backend.json`;
|
|
102
|
+
issues.push({
|
|
103
|
+
rule,
|
|
104
|
+
level: 'warning',
|
|
105
|
+
file: from,
|
|
106
|
+
message: `루트 공용 카탈로그(단일 파일 레이아웃)는 폐지됐습니다: ${from} (결정 459)\n` +
|
|
107
|
+
` 이 파일은 더 이상 읽히지 않습니다.\n` +
|
|
108
|
+
`→ 자동 이관: gaon doctor --fix (${from} → ${to} · 내용·키 무변경)\n` +
|
|
109
|
+
` 미분류 카탈로그는 **서버 전용**으로 옮깁니다(클라 노출은 되돌리기 어려운 방향이라\n` +
|
|
110
|
+
` 자동화하지 않습니다). 화면 문구는 그 뒤 apps/<앱>/locales/<로케일>/frontend.json 으로 옮기세요.`,
|
|
111
|
+
detail: { locale: lng, kind: 'root-legacy', from, to },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ② 앱 평면 `<로케일>.json` — 스코프 폴더 배치로 rename. 앱 카탈로그는 정의상
|
|
116
|
+
// frontend 였으므로(결정 454 R7) frontend.json 으로 간다 — backend 로 보내면
|
|
117
|
+
// 화면이 즉시 빈다(방향이 루트와 반대인 이유).
|
|
118
|
+
for (const app of apps) {
|
|
119
|
+
const dir = join(cwd, 'apps', app, 'locales');
|
|
120
|
+
for (const lng of legacyLocaleFiles(dir)) {
|
|
121
|
+
const from = relative(cwd, join(dir, `${lng}.json`));
|
|
122
|
+
const to = `apps/${app}/locales/${lng}/frontend.json`;
|
|
123
|
+
issues.push({
|
|
124
|
+
rule,
|
|
125
|
+
level: 'warning',
|
|
126
|
+
file: from,
|
|
127
|
+
message: `앱 카탈로그가 평면 파일 레이아웃입니다: ${from} (결정 459)\n` +
|
|
128
|
+
` 새 배치는 언어 폴더 + 스코프 파일입니다 — frontend(화면)와 backend(그 앱 서버 문구)를 나눕니다.\n` +
|
|
129
|
+
`→ 자동 이관: gaon doctor --fix (${from} → ${to} · 내용·키 무변경)\n` +
|
|
130
|
+
` 앱 카탈로그는 지금까지 화면 문구 전용이었으므로 frontend 로 옮깁니다.\n` +
|
|
131
|
+
` 그중 서버 전용 문구가 있으면 그 뒤 같은 폴더의 backend.json 으로 옮기세요.`,
|
|
132
|
+
detail: { locale: lng, kind: 'app-legacy', app, from, to },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ③ domain/locales/<로케일>/frontend.json — 도메인은 화면을 소유하지 않는다(O8).
|
|
137
|
+
// 로더도 부팅에서 throw 한다 — 여기서 같은 사유를 먼저 낸다(정적 미러).
|
|
138
|
+
const domainLocales = join(cwd, 'domain', 'locales');
|
|
139
|
+
for (const lng of localeDirs(domainLocales)) {
|
|
140
|
+
const f = join(domainLocales, lng, 'frontend.json');
|
|
141
|
+
if (!existsSync(f))
|
|
142
|
+
continue;
|
|
143
|
+
const from = relative(cwd, f);
|
|
144
|
+
issues.push({
|
|
145
|
+
rule,
|
|
146
|
+
level: 'error',
|
|
147
|
+
file: from,
|
|
148
|
+
message: `도메인 카탈로그에는 frontend.json 을 둘 수 없습니다: ${from} (결정 459)\n` +
|
|
149
|
+
` 화면 문구의 소유자는 **그 앱**입니다 — 도메인은 화면을 소유하지 않습니다.\n` +
|
|
150
|
+
` (이 상태로는 gaon serve 가 부팅에서 실패합니다.)\n` +
|
|
151
|
+
`→ apps/<앱>/locales/${lng}/frontend.json 으로 옮기세요(여러 앱이 쓰면 각 앱에 같은 키를).\n` +
|
|
152
|
+
`→ 서버 전용 문구라면 같은 폴더의 backend.json 으로 옮기세요.`,
|
|
153
|
+
detail: { locale: lng, kind: 'domain-frontend', from },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// ④ i18n.dir — 제거된 설정(O3). 위치가 관례로 고정된 이상 "정답이 둘" 을 만든다.
|
|
24
157
|
const cfg = analyzeProjectI18n(cwd);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 };
|
|
158
|
+
if (cfg.dir !== undefined) {
|
|
159
|
+
issues.push({
|
|
160
|
+
rule,
|
|
161
|
+
level: 'error',
|
|
162
|
+
file: 'gaon.config.ts',
|
|
163
|
+
message: `gaon.config.ts 의 i18n.dir 은 제거된 설정입니다(dir: '${cfg.dir}') — 결정 459\n` +
|
|
164
|
+
` 카탈로그 위치는 이제 관례로 고정입니다: domain/locales/ · apps/<앱>/locales/.\n` +
|
|
165
|
+
` 이 설정은 아무 효과가 없으므로, 남겨 두면 "설정한 폴더를 읽겠지" 라는 오해만 만듭니다.\n` +
|
|
166
|
+
`→ gaon.config.ts 의 i18n 블록에서 dir 을 지우세요: i18n: { fallbackLng: '...', supportedLngs: [...] }\n` +
|
|
167
|
+
`→ 그 폴더에 카탈로그가 있다면 gaon doctor --fix 로 새 위치에 이관하세요.`,
|
|
168
|
+
detail: { kind: 'config-dir', dir: cfg.dir },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return { rule, issues };
|
|
47
172
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 서버 번역 키의 소유자 경계 (결정 459 · 에러)
|
|
2
|
+
//
|
|
3
|
+
// **왜 새 검사가 필요한가**: 결정 459 가 서버 카탈로그를 두 소유자로 쪼개면서
|
|
4
|
+
// **지금까지 없던 실패 유형**이 생겼다 — 도메인 코드(`domain/**`)가 앱 backend 키를
|
|
5
|
+
// 쓰는 경우다. 이건 두 층 모두 놓친다:
|
|
6
|
+
//
|
|
7
|
+
// · 타입 — 서버 키 유니온은 domain ∪ 전 앱(결정 459 O5)이라 **컴파일을 통과**한다.
|
|
8
|
+
// 앱별로 유니온을 쪼개는 것은 TS2717 로 구조적으로 불가(결정 454 실측).
|
|
9
|
+
// · 런타임 — 요청 컨텍스트에서는 앱 네임스페이스가 상속돼(O6) **우연히 해석된다**.
|
|
10
|
+
// 같은 코드가 워커(`gaon work`)·크론에서 불리면 앱 스코프가 없어 키가 빈다.
|
|
11
|
+
//
|
|
12
|
+
// 즉 "개발 중엔 되는데 잡에서만 키 문자열이 뜬다" 는 가장 나쁜 형태로 샌다. 결정 455 의
|
|
13
|
+
// 2단 계약(빌드=차단 / 런타임=키 렌더+경고)에서 **빌드=차단의 절반이 무너지는** 지점이라
|
|
14
|
+
// 정적으로 못박는다.
|
|
15
|
+
//
|
|
16
|
+
// 검사 대상은 서버 t(`import { t } from 'gaonjs/i18n'`)뿐이다 — 클라 t(`gaonjs/vue`)의
|
|
17
|
+
// 앱 경계는 `i18n-app-scope` 가 소유한다(축이 다르고 수리 안내가 갈린다 · ADR §6-C).
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { relative } from 'node:path';
|
|
20
|
+
import { flattenKeys, loadProjectLocales, withPluralBaseKeys } from '@gaonjs/i18n';
|
|
21
|
+
import { analyzeProjectI18n } from '../i18n-config.js';
|
|
22
|
+
import { stripComments, walkSources, T_CALL } from './i18n-app-scope.js';
|
|
23
|
+
// `import { t } from 'gaonjs/i18n'` 를 쓴 파일에서만 본다.
|
|
24
|
+
const SERVER_T_IMPORT = /from\s+['"]gaonjs\/i18n['"]/;
|
|
25
|
+
/** 카탈로그의 전 로케일 키 합집합(복수형 base 포함). */
|
|
26
|
+
function keysOf(...resources) {
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const res of resources) {
|
|
29
|
+
for (const entry of Object.values(res)) {
|
|
30
|
+
for (const k of withPluralBaseKeys(flattenKeys(entry.translation)))
|
|
31
|
+
out.add(k);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export async function checkI18nServerScope(cwd) {
|
|
37
|
+
const rule = 'i18n-server-scope';
|
|
38
|
+
// i18n 을 안 쓰는 프로젝트는 무소음(카탈로그가 없으면 키 검사 자체가 의미 없다).
|
|
39
|
+
if (!analyzeProjectI18n(cwd).declared)
|
|
40
|
+
return { rule, issues: [] };
|
|
41
|
+
let project;
|
|
42
|
+
try {
|
|
43
|
+
project = loadProjectLocales(cwd);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 로더 fail-loud(도메인 frontend.json 등)는 i18n-layout·check 가 소유한다 — 여기선 침묵.
|
|
47
|
+
return { rule, issues: [] };
|
|
48
|
+
}
|
|
49
|
+
const domainKeys = keysOf(project.domain.backend);
|
|
50
|
+
if (domainKeys.size === 0 && Object.keys(project.apps).length === 0)
|
|
51
|
+
return { rule, issues: [] };
|
|
52
|
+
const issues = [];
|
|
53
|
+
// ① 도메인 코드 — domain-backend 만 보인다. 앱 스코프는 요청 컨텍스트에서만 상속되므로
|
|
54
|
+
// 도메인이 앱 키에 기대면 워커·크론에서 조용히 빈다.
|
|
55
|
+
for (const file of walkSources(`${cwd}/domain`, ['.ts'])) {
|
|
56
|
+
const raw = readFileSync(file, 'utf8');
|
|
57
|
+
if (!SERVER_T_IMPORT.test(raw))
|
|
58
|
+
continue;
|
|
59
|
+
const src = stripComments(raw);
|
|
60
|
+
for (const m of src.matchAll(T_CALL)) {
|
|
61
|
+
const key = m[1];
|
|
62
|
+
if (domainKeys.has(key))
|
|
63
|
+
continue;
|
|
64
|
+
const line = src.slice(0, m.index).split('\n').length;
|
|
65
|
+
const rel = relative(cwd, file);
|
|
66
|
+
const owner = appsOwning(project, key);
|
|
67
|
+
issues.push({
|
|
68
|
+
rule,
|
|
69
|
+
level: 'error',
|
|
70
|
+
file: rel,
|
|
71
|
+
line,
|
|
72
|
+
message: (owner.length > 0
|
|
73
|
+
? `'${key}' 는 앱 전용 번역 키인데 도메인 코드가 씁니다(소유 앱: ${owner.join(', ')}): ${rel}:${line} (결정 459)\n`
|
|
74
|
+
: `'${key}' 를 도메인 카탈로그에서 찾을 수 없습니다: ${rel}:${line} (결정 459)\n`) +
|
|
75
|
+
` 도메인 코드는 요청 밖(잡·크론·워커)에서도 불립니다 — 그때는 앱 스코프가 없어\n` +
|
|
76
|
+
` 번역 대신 키 문자열이 그대로 나갑니다(요청 안에서는 우연히 해석돼 더 늦게 발견됩니다).\n` +
|
|
77
|
+
`→ 이 문구를 도메인이 소유해야 하면 domain/locales/<로케일>/backend.json 으로 옮기고 gaon gen 을 실행하세요.\n` +
|
|
78
|
+
`→ 화면·앱 전용 문구라면 이 호출을 도메인 밖(앱 컨트롤러)으로 옮기세요.`,
|
|
79
|
+
detail: { key, scope: 'domain', ownedByApps: owner },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// ② 앱 서버 코드 — app-backend ∪ app-frontend ∪ domain-backend 가 보인다(O5).
|
|
84
|
+
for (const [app, scoped] of Object.entries(project.apps)) {
|
|
85
|
+
const own = keysOf(scoped.backend, scoped.frontend, project.domain.backend);
|
|
86
|
+
for (const file of walkSources(`${cwd}/apps/${app}`, ['.ts'])) {
|
|
87
|
+
const raw = readFileSync(file, 'utf8');
|
|
88
|
+
if (!SERVER_T_IMPORT.test(raw))
|
|
89
|
+
continue;
|
|
90
|
+
const src = stripComments(raw);
|
|
91
|
+
for (const m of src.matchAll(T_CALL)) {
|
|
92
|
+
const key = m[1];
|
|
93
|
+
if (own.has(key))
|
|
94
|
+
continue;
|
|
95
|
+
const line = src.slice(0, m.index).split('\n').length;
|
|
96
|
+
const rel = relative(cwd, file);
|
|
97
|
+
const owner = appsOwning(project, key).filter((a) => a !== app);
|
|
98
|
+
issues.push({
|
|
99
|
+
rule,
|
|
100
|
+
level: 'error',
|
|
101
|
+
file: rel,
|
|
102
|
+
line,
|
|
103
|
+
message: (owner.length > 0
|
|
104
|
+
? `'${key}' 는 **다른 앱 전용** 번역 키입니다(소유 앱: ${owner.join(', ')}): ${rel}:${line} (결정 459)\n`
|
|
105
|
+
: `'${key}' 를 이 앱·도메인 카탈로그 어디에서도 찾을 수 없습니다: ${rel}:${line} (결정 459)\n`) +
|
|
106
|
+
` 앱 스코프 t() 는 apps/${app}/locales 와 domain/locales 만 봅니다 — 없으면 키 문자열이 그대로 나갑니다.\n` +
|
|
107
|
+
`→ 이 앱 전용 서버 문구면 apps/${app}/locales/<로케일>/backend.json 에,\n` +
|
|
108
|
+
`→ 메일·잡 등 도메인 공통 문구면 domain/locales/<로케일>/backend.json 에 추가하고 gaon gen 을 실행하세요.`,
|
|
109
|
+
detail: { key, scope: `app:${app}`, app, ownedByApps: owner },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { rule, issues };
|
|
115
|
+
}
|
|
116
|
+
/** 이 키를 가진 앱 목록(안내에 "어디 있는 키인지" 를 정확히 쓰기 위해). */
|
|
117
|
+
function appsOwning(project, key) {
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const [app, scoped] of Object.entries(project.apps)) {
|
|
120
|
+
if (keysOf(scoped.backend, scoped.frontend).has(key))
|
|
121
|
+
out.push(app);
|
|
122
|
+
}
|
|
123
|
+
return out.sort();
|
|
124
|
+
}
|