@gaonjs/cli 0.64.0 → 0.65.2
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.d.ts +1 -1
- package/dist/doctor/fixers/index.js +13 -3
- 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/redundant-index.d.ts +7 -0
- package/dist/doctor/redundant-index.js +67 -0
- package/dist/doctor/schema-relations.d.ts +13 -0
- package/dist/doctor/schema-relations.js +5 -1
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +42 -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 +8 -6
- package/dist/templates/project/agents/data.md.tpl +6 -0
- package/dist/templates/project/agents/frontend.md.tpl +218 -75
- package/dist/templates/project/agents/i18n.md.tpl +165 -70
- package/dist/templates/project/agents/web.md.tpl +4 -3
- 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
package/dist/commands/check.js
CHANGED
|
@@ -32,8 +32,8 @@ import { generateTablesDts } from '@gaonjs/data';
|
|
|
32
32
|
import { generateRoutesDts } from '@gaonjs/web';
|
|
33
33
|
import { computeDoctorResult } from '../doctor.js';
|
|
34
34
|
import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
|
|
35
|
-
import { analyzeProjectI18n
|
|
36
|
-
import {
|
|
35
|
+
import { analyzeProjectI18n } from '../i18n-config.js';
|
|
36
|
+
import { assertNoOwnerCollision, loadProjectLocales, mergeScopes, projectLanguages, } from '@gaonjs/i18n';
|
|
37
37
|
import { registerTsResolve } from '../tsResolve.js';
|
|
38
38
|
import { listFrontendApps, verifyAppDist } from '../dev/build.js';
|
|
39
39
|
import { generateMessagesDts } from '../messages-gen.js';
|
|
@@ -250,33 +250,49 @@ function verifyI18nCatalogs(cwd) {
|
|
|
250
250
|
const cfg = analyzeProjectI18n(cwd);
|
|
251
251
|
if (!cfg.declared)
|
|
252
252
|
return undefined;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
253
|
+
// 결정 459: 판정 대상이 2소유자로 늘었다 — 로더가 그대로 두 소유자를 읽는다
|
|
254
|
+
// (`domain/locales/*/frontend.json` 이면 여기서 O8 throw 가 나고 그 메시지가 곧 수리 안내).
|
|
255
|
+
let project;
|
|
256
|
+
try {
|
|
257
|
+
project = loadProjectLocales(cwd);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
return err instanceof Error ? err.message : String(err);
|
|
261
|
+
}
|
|
262
|
+
const langs = projectLanguages(project);
|
|
263
|
+
if (langs.length === 0) {
|
|
264
|
+
const base = cfg.fallbackLng ?? '<fallbackLng>';
|
|
265
|
+
return (`i18n 이 설정됐지만 로케일 카탈로그가 하나도 없습니다: ${cwd} (결정 459)\n` +
|
|
266
|
+
` → 화면 문구: apps/<앱>/locales/${base}/frontend.json (앱마다 필수)\n` +
|
|
267
|
+
` → 도메인 공통 서버 문구(메일·잡·검증): domain/locales/${base}/backend.json\n` +
|
|
268
|
+
` → 그 앱 컨트롤러 전용 서버 문구: apps/<앱>/locales/${base}/backend.json (선택)\n` +
|
|
269
|
+
` → 루트 locales/ 는 더 이상 읽지 않습니다 — gaon doctor --fix 로 이관하세요.\n` +
|
|
262
270
|
` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
|
|
263
271
|
}
|
|
264
272
|
const wanted = [...(cfg.fallbackLng ? [cfg.fallbackLng] : []), ...(cfg.supportedLngs ?? [])];
|
|
265
|
-
const missing = wanted.filter((lng, i, arr) => arr.indexOf(lng) === i && !
|
|
273
|
+
const missing = wanted.filter((lng, i, arr) => arr.indexOf(lng) === i && !langs.includes(lng));
|
|
266
274
|
if (missing.length > 0) {
|
|
267
|
-
return (`i18n 설정이 지목한 로케일의 카탈로그가 없습니다: ${missing.join(', ')}\n` +
|
|
268
|
-
` →
|
|
269
|
-
|
|
270
|
-
.join(' · ')})을 추가하거나,\n` +
|
|
271
|
-
` → 단일 파일 레이아웃이면 ${missing.map((l) => `${l}.json`).join(' · ')} 을 추가하거나,\n` +
|
|
275
|
+
return (`i18n 설정이 지목한 로케일의 카탈로그가 없습니다: ${missing.join(', ')} (결정 459)\n` +
|
|
276
|
+
` → 각 앱에 ${missing.map((l) => `apps/<앱>/locales/${l}/frontend.json`).join(' · ')} 을 추가하거나,\n` +
|
|
277
|
+
` → 도메인 공통 문구면 ${missing.map((l) => `domain/locales/${l}/backend.json`).join(' · ')} 을 추가하거나,\n` +
|
|
272
278
|
` → gaon.config.ts 의 fallbackLng/supportedLngs 에서 해당 로케일을 제거하세요.\n` +
|
|
273
279
|
` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
|
|
274
280
|
}
|
|
281
|
+
// 결정 459 O2: 소유자 간 키 충돌은 부팅 fail-loud 다 — 같은 사유를 check 에서 먼저 낸다.
|
|
282
|
+
try {
|
|
283
|
+
const apps = {};
|
|
284
|
+
for (const [name, scoped] of Object.entries(project.apps))
|
|
285
|
+
apps[name] = mergeScopes(scoped);
|
|
286
|
+
assertNoOwnerCollision(mergeScopes(project.domain), apps);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
return err instanceof Error ? err.message : String(err);
|
|
290
|
+
}
|
|
275
291
|
return undefined;
|
|
276
292
|
}
|
|
277
293
|
async function regenerateGaon(cwd) {
|
|
278
294
|
const layout = resolveDevLayout(cwd);
|
|
279
|
-
if (!layout.schemaDir && layout.apps.length === 0 &&
|
|
295
|
+
if (!layout.schemaDir && layout.apps.length === 0 && layout.localeDirs.length === 0) {
|
|
280
296
|
// 카탈로그가 통째로 없어도 config 가 i18n 을 켰으면 부팅이 죽는다 — 그 경우만 잡는다.
|
|
281
297
|
const problem = verifyI18nCatalogs(cwd);
|
|
282
298
|
if (problem)
|
package/dist/commands/g.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare function parseGenerateArgs(argv: readonly string[]): {
|
|
|
26
26
|
/** 타입에 따라 스캐폴드 파일 목록을 만든다. */
|
|
27
27
|
export declare function planScaffold(type: GenerateType, name: string, app: string, opts?: {
|
|
28
28
|
instance?: boolean;
|
|
29
|
+
fallbackLng?: string;
|
|
29
30
|
}): ScaffoldFile[];
|
|
30
31
|
/** `gaon g <type> <name>` 실행. exitCode 를 반환한다(0=성공, 1=실패). */
|
|
31
32
|
export declare function runGenerateCommand(type: GenerateType, name: string, opts?: GenerateOptions): number;
|
package/dist/commands/g.js
CHANGED
|
@@ -18,6 +18,7 @@ import { existsSync } from 'node:fs';
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { appScaffoldFiles, channelScaffoldFiles, controllerScaffold, inflectModel, jobScaffold, jobTestScaffold, modelScaffoldFiles, pageScaffold, writeScaffold, } from '../scaffold/index.js';
|
|
20
20
|
import { appWiringFiles, readProjectName } from '../scaffold/app-wiring.js';
|
|
21
|
+
import { analyzeProjectI18n } from '../i18n-config.js';
|
|
21
22
|
/** argv 에서 옵션을 뽑는다(간단 파서 · runCli 관례와 일치). */
|
|
22
23
|
export function parseGenerateArgs(argv) {
|
|
23
24
|
const type = argv[0];
|
|
@@ -70,7 +71,9 @@ export function planScaffold(type, name, app, opts = {}) {
|
|
|
70
71
|
}
|
|
71
72
|
// app 은 이름 자체가 앱 폴더명이 되므로 '/' 는 허용하지 않는다(app.ts 재검증).
|
|
72
73
|
if (type === 'app') {
|
|
73
|
-
|
|
74
|
+
// 결정 459: i18n 을 쓰는 프로젝트면 그 앱의 frontend 카탈로그도 함께 만든다
|
|
75
|
+
// (화면 문구의 소유자가 앱이므로 앱마다 필수다).
|
|
76
|
+
return appScaffoldFiles(name, { fallbackLng: opts.fallbackLng });
|
|
74
77
|
}
|
|
75
78
|
if (name.includes('/')) {
|
|
76
79
|
throw new Error(`${type} 이름에 '/' 는 사용할 수 없습니다: ${name}`);
|
|
@@ -94,7 +97,13 @@ export function runGenerateCommand(type, name, opts = {}) {
|
|
|
94
97
|
const app = opts.app ?? 'web';
|
|
95
98
|
let files;
|
|
96
99
|
try {
|
|
97
|
-
|
|
100
|
+
// 결정 459: 기준 로케일은 gaon.config.ts 의 i18n.fallbackLng(정적 분석 · 결정 352).
|
|
101
|
+
// i18n 미선언 프로젝트면 undefined → 카탈로그를 만들지 않는다.
|
|
102
|
+
const i18nCfg = analyzeProjectI18n(cwd);
|
|
103
|
+
files = planScaffold(type, name, app, {
|
|
104
|
+
instance: opts.instance,
|
|
105
|
+
fallbackLng: i18nCfg.declared ? (i18nCfg.fallbackLng ?? 'en') : undefined,
|
|
106
|
+
});
|
|
98
107
|
}
|
|
99
108
|
catch (err) {
|
|
100
109
|
const msg = err instanceof Error ? err.message : String(err);
|
package/dist/commands/gen.js
CHANGED
|
@@ -26,7 +26,7 @@ import { registerTsResolve } from '../tsResolve.js';
|
|
|
26
26
|
*/
|
|
27
27
|
export async function regenerateProjectGaon(cwd) {
|
|
28
28
|
const layout = resolveDevLayout(cwd);
|
|
29
|
-
if (!layout.schemaDir && layout.apps.length === 0 &&
|
|
29
|
+
if (!layout.schemaDir && layout.apps.length === 0 && layout.localeDirs.length === 0) {
|
|
30
30
|
return { tables: false, apps: [], messages: false, env: false, skipped: true };
|
|
31
31
|
}
|
|
32
32
|
registerTsResolve();
|
package/dist/dev.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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 (
|
|
7
|
+
/** apps/<app>/locales (그 앱의 스코프 카탈로그 · 결정 454 · 459). */
|
|
8
8
|
readonly localesDir: string;
|
|
9
9
|
/** apps/<app>/.gaon/messages.catalog.ts (클라 카탈로그 값 모듈 · 결정 454). */
|
|
10
10
|
readonly catalogOut: string;
|
|
@@ -16,13 +16,18 @@ export interface MessagesAppRef {
|
|
|
16
16
|
readonly catalogOut: string;
|
|
17
17
|
}
|
|
18
18
|
export interface DevLayout {
|
|
19
|
+
/** 프로젝트 루트 — 메시지 축 입력(두 소유자 카탈로그를 관례로 찾는다 · 결정 459). */
|
|
20
|
+
readonly root: string;
|
|
19
21
|
/** domain/schema (없으면 undefined — tables 워치 생략). */
|
|
20
22
|
readonly schemaDir?: string;
|
|
21
23
|
/** .gaon/tables.d.ts */
|
|
22
24
|
readonly tablesOut: string;
|
|
23
25
|
readonly apps: readonly DevApp[];
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 워치할 카탈로그 폴더들 — `domain/locales` + `apps/<앱>/locales` 중 **존재하는 것**.
|
|
28
|
+
* 비면 messages 축 생략(결정 158 · W2 · 결정 459 로 루트 단일 폴더에서 N개로).
|
|
29
|
+
*/
|
|
30
|
+
readonly localeDirs: readonly string[];
|
|
26
31
|
/** 기준 로케일 = config i18n.fallbackLng (정적 분석 · 결정 352 · 못 읽으면 undefined). */
|
|
27
32
|
readonly messagesBaseLng?: string;
|
|
28
33
|
/** .gaon/messages.d.ts */
|
|
@@ -39,10 +44,11 @@ export interface DevDeps {
|
|
|
39
44
|
regenerateTables(schemaDir: string, out: string): Promise<unknown>;
|
|
40
45
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
41
46
|
/**
|
|
42
|
-
*
|
|
47
|
+
* 프로젝트 카탈로그 → .gaon/messages.d.ts (결정 158 · W2 · 기준 로케일 = 결정 352) + 앱별
|
|
43
48
|
* messages.catalog.ts (결정 454 · apps 를 넘긴 호출자만). i18n 축을 쓰는 호출자만 준다.
|
|
49
|
+
* 첫 인자는 **프로젝트 루트**다(결정 459 — 카탈로그 위치는 관례 고정).
|
|
44
50
|
*/
|
|
45
|
-
regenerateMessages?(
|
|
51
|
+
regenerateMessages?(root: string, out: string, baseLng?: string, apps?: readonly MessagesAppRef[]): unknown;
|
|
46
52
|
/** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
|
|
47
53
|
regenerateEnv?(envFile: string, out: string): unknown;
|
|
48
54
|
watch(dir: string, opts: WatchOptions): WatchHandle;
|
|
@@ -73,8 +79,8 @@ export declare function startDev(deps: DevDeps): Promise<DevHandle>;
|
|
|
73
79
|
export interface RegenDeps {
|
|
74
80
|
regenerateTables(schemaDir: string, out: string): Promise<unknown>;
|
|
75
81
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
76
|
-
/**
|
|
77
|
-
regenerateMessages?(
|
|
82
|
+
/** 프로젝트 루트 → .gaon/messages.d.ts + 앱별 messages.catalog.ts (결정 158 · 352 · 454 · 459). */
|
|
83
|
+
regenerateMessages?(root: string, out: string, baseLng?: string, apps?: readonly MessagesAppRef[]): unknown;
|
|
78
84
|
/** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
|
|
79
85
|
regenerateEnv?(envFile: string, out: string): unknown;
|
|
80
86
|
}
|
package/dist/dev.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { readdirSync, existsSync } from 'node:fs';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
|
-
import {
|
|
16
|
+
import { domainLocalesDir } from '@gaonjs/i18n';
|
|
17
|
+
import { analyzeProjectI18n } from './i18n-config.js';
|
|
17
18
|
/** locales/*.json 변경(메시지 카탈로그) — messages.d.ts 재생성 트리거. */
|
|
18
19
|
const isMessagesChange = (f) => !f.includes('.gaon') && f.endsWith('.json');
|
|
19
20
|
const isSchemaChange = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && !f.includes('.gaon');
|
|
@@ -38,9 +39,9 @@ export async function startDev(deps) {
|
|
|
38
39
|
await deps.regenerateRoutes(app.appDir, app.routesOut);
|
|
39
40
|
deps.log({ kind: 'regen', target: 'routes', app: app.name });
|
|
40
41
|
}
|
|
41
|
-
// 결정 158(W2):
|
|
42
|
-
if (layout.
|
|
43
|
-
await deps.regenerateMessages(layout.
|
|
42
|
+
// 결정 158(W2): 프로젝트 카탈로그 → messages.d.ts.
|
|
43
|
+
if (layout.localeDirs.length > 0 && deps.regenerateMessages) {
|
|
44
|
+
await deps.regenerateMessages(layout.root, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout));
|
|
44
45
|
deps.log({ kind: 'regen', target: 'messages' });
|
|
45
46
|
}
|
|
46
47
|
// 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts(VITE_* 타입 브리지). `.env` 부재는
|
|
@@ -73,18 +74,20 @@ export async function startDev(deps) {
|
|
|
73
74
|
onError: deps.onError,
|
|
74
75
|
}));
|
|
75
76
|
}
|
|
76
|
-
// 4)
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// 4) 카탈로그 워치 → messages.d.ts 재생성(결정 158 · W2). 결정 459: 소유자가 둘이라
|
|
78
|
+
// 폴더가 N개다 — 어느 쪽이 바뀌든 같은 재생성 1회를 돈다(산출은 루트 단일 + 앱별 값 모듈).
|
|
79
|
+
if (layout.localeDirs.length > 0 && deps.regenerateMessages) {
|
|
79
80
|
const regenerateMessages = deps.regenerateMessages;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
for (const localesDir of layout.localeDirs) {
|
|
82
|
+
handles.push(deps.watch(localesDir, {
|
|
83
|
+
filter: isMessagesChange,
|
|
84
|
+
onChange: async () => {
|
|
85
|
+
await regenerateMessages(layout.root, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout));
|
|
86
|
+
deps.log({ kind: 'regen', target: 'messages' });
|
|
87
|
+
},
|
|
88
|
+
onError: deps.onError,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
88
91
|
}
|
|
89
92
|
deps.log({ kind: 'ready', schema: !!layout.schemaDir, apps: layout.apps.map((a) => a.name) });
|
|
90
93
|
return {
|
|
@@ -108,11 +111,11 @@ export async function regenerateGaonOnce(layout, deps) {
|
|
|
108
111
|
for (const app of layout.apps) {
|
|
109
112
|
await deps.regenerateRoutes(app.appDir, app.routesOut);
|
|
110
113
|
}
|
|
111
|
-
// 결정 158(W2):
|
|
114
|
+
// 결정 158(W2): 카탈로그가 있으면 messages.d.ts 도 재생성한다(i18n 키 타입 브리지).
|
|
112
115
|
let messages = false;
|
|
113
|
-
if (layout.
|
|
116
|
+
if (layout.localeDirs.length > 0 && deps.regenerateMessages) {
|
|
114
117
|
messages =
|
|
115
|
-
(await deps.regenerateMessages(layout.
|
|
118
|
+
(await deps.regenerateMessages(layout.root, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout))) === true;
|
|
116
119
|
}
|
|
117
120
|
// 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts. `.env` 부재는 throw(수리 안내).
|
|
118
121
|
let env = false;
|
|
@@ -152,15 +155,20 @@ export function resolveDevLayout(cwd) {
|
|
|
152
155
|
});
|
|
153
156
|
}
|
|
154
157
|
}
|
|
155
|
-
// 결정 352:
|
|
156
|
-
//
|
|
158
|
+
// 결정 352: 기준 로케일은 gaon.config.ts 의 i18n.fallbackLng 를 따른다(정적 분석).
|
|
159
|
+
// 결정 459: **위치는 관례 고정**이라 더 이상 config(i18n.dir)를 보지 않는다 —
|
|
160
|
+
// domain/locales + 앱별 locales 중 존재하는 폴더가 곧 워치 대상이다.
|
|
157
161
|
const i18nCfg = analyzeProjectI18n(root);
|
|
158
|
-
const
|
|
162
|
+
const localeDirs = [
|
|
163
|
+
domainLocalesDir(root),
|
|
164
|
+
...apps.map((a) => a.localesDir),
|
|
165
|
+
].filter((d) => existsSync(d));
|
|
159
166
|
return {
|
|
167
|
+
root,
|
|
160
168
|
schemaDir: existsSync(schemaDirPath) ? schemaDirPath : undefined,
|
|
161
169
|
tablesOut: join(root, '.gaon', 'tables.d.ts'),
|
|
162
170
|
apps: apps.sort((a, b) => a.name.localeCompare(b.name)),
|
|
163
|
-
|
|
171
|
+
localeDirs,
|
|
164
172
|
messagesBaseLng: i18nCfg.fallbackLng,
|
|
165
173
|
messagesOut: join(root, '.gaon', 'messages.d.ts'),
|
|
166
174
|
hasFrontendApps,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DoctorCheck } from '../types.js';
|
|
2
2
|
import type { FixerPlan } from './types.js';
|
|
3
3
|
/**
|
|
4
|
-
* i18n-layout
|
|
5
|
-
*
|
|
4
|
+
* i18n-layout 위반을 새 배치로 옮기는 계획을 만든다. 내용·키는 바뀌지 않는다
|
|
5
|
+
* (④ 의 병합만 앱 기존 키 위에 없는 키를 더한다).
|
|
6
6
|
*/
|
|
7
7
|
export declare function fixI18nLayout(issues: readonly DoctorCheck[], cwd: string): Promise<readonly FixerPlan[]>;
|
|
@@ -1,40 +1,134 @@
|
|
|
1
|
-
// @gaonjs/cli · doctor fixer ·
|
|
1
|
+
// @gaonjs/cli · doctor fixer · 옛 카탈로그 배치 → 결정 459 배치 이관
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 이관 4종(검출은 doctor/i18n-layout.ts):
|
|
4
|
+
// ① locales/<로케일>/backend.json → domain/locales/<로케일>/backend.json rename
|
|
5
|
+
// ② locales/<로케일>.json → domain/locales/<로케일>/backend.json rename
|
|
6
|
+
// ③ apps/<앱>/locales/<로케일>.json → apps/<앱>/locales/<로케일>/frontend.json rename
|
|
7
|
+
// ④ locales/<로케일>/frontend.json → **전 앱** frontend.json 에 병합 + 원본 제거
|
|
4
8
|
//
|
|
5
|
-
// **왜
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
+
// **왜 ④ 만 복제인가(자동화의 안전 방향)**: 공용 화면 문구 중 어떤 키를 어느 앱이
|
|
10
|
+
// 실제로 쓰는지는 정적으로 판정할 수 없다(동적 키 `t(변수)` 가 섞이면 오판이 곧 화면
|
|
11
|
+
// 누락이다). 그래서 fixer 는 **전 앱 복제만** 하고, 잉여 키 정리는 사람이 한다 —
|
|
12
|
+
// 되돌리기 쉬운 방향이다(결정 454 fixer 가 "전량 backend" 를 고른 것과 같은 논리).
|
|
13
|
+
//
|
|
14
|
+
// **병합 우선순위**: 앱에 이미 있는 키가 이긴다. 공용 사본이 앱 전용 문안을 덮으면
|
|
15
|
+
// 이관이 곧 회귀가 된다(같은 키를 앱이 다르게 쓰고 있었다면 그쪽이 최신 의도다).
|
|
9
16
|
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
10
18
|
import { join } from 'node:path';
|
|
19
|
+
/** 중첩 객체 깊은 병합 — `base` 가 이긴다(앱 기존 키 보존). */
|
|
20
|
+
function deepMerge(base, extra) {
|
|
21
|
+
const isTree = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
22
|
+
const out = { ...base };
|
|
23
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
24
|
+
if (!(k in out)) {
|
|
25
|
+
out[k] = v;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const cur = out[k];
|
|
29
|
+
if (isTree(cur) && isTree(v))
|
|
30
|
+
out[k] = deepMerge(cur, v);
|
|
31
|
+
// 리프 충돌은 base(앱)를 유지한다 — 덮어쓰지 않는다.
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
async function readJson(path) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 카탈로그 JSON 직렬화(2칸 들여쓰기 + 개행 · 저장소 관례). */
|
|
44
|
+
function stringify(tree) {
|
|
45
|
+
return `${JSON.stringify(tree, null, 2)}\n`;
|
|
46
|
+
}
|
|
11
47
|
/**
|
|
12
|
-
* i18n-layout
|
|
13
|
-
*
|
|
48
|
+
* i18n-layout 위반을 새 배치로 옮기는 계획을 만든다. 내용·키는 바뀌지 않는다
|
|
49
|
+
* (④ 의 병합만 앱 기존 키 위에 없는 키를 더한다).
|
|
14
50
|
*/
|
|
15
51
|
export async function fixI18nLayout(issues, cwd) {
|
|
16
52
|
const plans = [];
|
|
17
53
|
for (const issue of issues) {
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
54
|
+
const kind = issue.detail?.kind;
|
|
55
|
+
if (issue.file === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
// ①②③ — 1:1 이동(내용 무변경).
|
|
58
|
+
if (kind === 'root-backend' || kind === 'root-legacy' || kind === 'app-legacy') {
|
|
59
|
+
const to = issue.detail?.to;
|
|
60
|
+
if (typeof to !== 'string')
|
|
61
|
+
continue;
|
|
62
|
+
let before;
|
|
63
|
+
try {
|
|
64
|
+
before = await readFile(join(cwd, issue.file), 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue; // 그 사이 파일이 사라졌으면 계획에서 제외(부분 적용 방지).
|
|
68
|
+
}
|
|
69
|
+
plans.push({
|
|
70
|
+
kind: 'rename',
|
|
71
|
+
file: issue.file,
|
|
72
|
+
to,
|
|
73
|
+
before,
|
|
74
|
+
summary: `카탈로그를 새 배치로 이동: ${issue.file} → ${to} (결정 459)\n` +
|
|
75
|
+
` 내용·키는 그대로다.` +
|
|
76
|
+
(kind === 'app-legacy'
|
|
77
|
+
? ' 앱 카탈로그는 화면 문구 전용이었으므로 frontend 로 간다.'
|
|
78
|
+
: ' 미분류·공용 서버 문구는 도메인 backend 로 간다(클라 노출 없음).'),
|
|
79
|
+
refEdits: [], // JSON 카탈로그는 소스가 import 하지 않는다(경로 참조 갱신 없음).
|
|
80
|
+
});
|
|
20
81
|
continue;
|
|
21
|
-
let before;
|
|
22
|
-
try {
|
|
23
|
-
before = await readFile(join(cwd, issue.file), 'utf8');
|
|
24
82
|
}
|
|
25
|
-
|
|
26
|
-
|
|
83
|
+
// ④ — 전 앱 복제 후 원본 제거.
|
|
84
|
+
if (kind === 'root-frontend') {
|
|
85
|
+
const targets = issue.detail?.to;
|
|
86
|
+
const locale = issue.detail?.locale;
|
|
87
|
+
if (!Array.isArray(targets) || targets.length === 0 || typeof locale !== 'string')
|
|
88
|
+
continue;
|
|
89
|
+
const sourcePath = join(cwd, issue.file);
|
|
90
|
+
let before;
|
|
91
|
+
let tree;
|
|
92
|
+
try {
|
|
93
|
+
before = await readFile(sourcePath, 'utf8');
|
|
94
|
+
tree = JSON.parse(before);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const target of targets) {
|
|
100
|
+
if (typeof target !== 'string')
|
|
101
|
+
continue;
|
|
102
|
+
const abs = join(cwd, target);
|
|
103
|
+
const existing = existsSync(abs) ? await readJson(abs) : undefined;
|
|
104
|
+
// 앱 기존 키가 이긴다 — 공용 사본이 앱 전용 문안을 덮지 않는다.
|
|
105
|
+
const merged = stringify(deepMerge(existing ?? {}, tree));
|
|
106
|
+
const prior = existing === undefined ? '' : await readFile(abs, 'utf8');
|
|
107
|
+
if (prior === merged)
|
|
108
|
+
continue; // 이미 반영됨(멱등).
|
|
109
|
+
plans.push({
|
|
110
|
+
kind: 'rewrite',
|
|
111
|
+
file: target,
|
|
112
|
+
before: prior,
|
|
113
|
+
after: merged,
|
|
114
|
+
summary: `공용 화면 문구를 앱 카탈로그로 복제: ${issue.file} → ${target} (결정 459)\n` +
|
|
115
|
+
` 화면 문구의 소유자는 그 앱이다 — 공용 스코프가 폐지돼 앱마다 사본을 둔다.\n` +
|
|
116
|
+
` 앱에 이미 있던 키는 그대로 유지된다(공용 사본이 덮지 않는다).\n` +
|
|
117
|
+
` → 이 앱이 실제로 쓰지 않는 키는 사람이 지운다(전 앱 복제는 안전한 방향의 자동화).`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
plans.push({
|
|
121
|
+
kind: 'remove',
|
|
122
|
+
file: issue.file,
|
|
123
|
+
before,
|
|
124
|
+
summary: `폐지된 공용 카탈로그 제거: ${issue.file} (결정 459)\n` +
|
|
125
|
+
` 위 복제가 끝난 뒤 지운다 — 남겨 두면 아무도 읽지 않는 폴더가 되어\n` +
|
|
126
|
+
` "고쳤는데 화면이 안 바뀐다" 를 만든다. 원본은 .bak 로 백업된다.`,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
27
129
|
}
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
});
|
|
130
|
+
// ③(domain-frontend)·④(config-dir)는 자동 수정하지 않는다 — 어느 앱이 그 문구를
|
|
131
|
+
// 소유하는지(전자)·설정 의도(후자)는 사람의 판단이라 doctor 메시지가 안내만 한다.
|
|
38
132
|
}
|
|
39
133
|
return plans;
|
|
40
134
|
}
|
|
@@ -15,7 +15,7 @@ export declare const FIXERS: Partial<Record<DoctorRule, Fixer>>;
|
|
|
15
15
|
* 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
|
|
16
16
|
* 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
|
|
17
17
|
*
|
|
18
|
-
* **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES`
|
|
18
|
+
* **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 전종을 **빠짐없이** 담는다 —
|
|
19
19
|
* fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
|
|
20
20
|
* 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
|
|
21
21
|
* 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
|
|
@@ -31,7 +31,7 @@ export const FIXERS = {
|
|
|
31
31
|
* 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
|
|
32
32
|
* 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
|
|
33
33
|
*
|
|
34
|
-
* **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES`
|
|
34
|
+
* **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 전종을 **빠짐없이** 담는다 —
|
|
35
35
|
* fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
|
|
36
36
|
* 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
|
|
37
37
|
* 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
|
|
@@ -41,12 +41,17 @@ export const FIXER_CAPABILITIES = [
|
|
|
41
41
|
{
|
|
42
42
|
rule: 'i18n-layout',
|
|
43
43
|
hasFixer: true,
|
|
44
|
-
note: '자동 · locales
|
|
44
|
+
note: '자동 · 옛 배치 → 결정 459 배치 이관(루트 backend·레거시 단일 파일 → domain/locales, 앱 평면 파일 → apps/<앱>/locales/<로케일>/frontend.json, 루트 공용 frontend → 전 앱 복제 후 원본 제거). domain/locales 의 frontend.json 과 i18n.dir 잔재는 사람이 판단(어느 앱이 소유하는지·설정 의도).',
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
rule: 'i18n-app-scope',
|
|
48
48
|
hasFixer: false,
|
|
49
|
-
note: '수동 · 그 키를
|
|
49
|
+
note: '수동 · 그 키를 apps/<앱>/locales/<로케일>/frontend.json 에 추가(어느 앱 문구인지는 설계 판단 · shared/ 가 쓰는 키는 모든 앱에).',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
rule: 'i18n-server-scope',
|
|
53
|
+
hasFixer: false,
|
|
54
|
+
note: '수동 · 문구를 domain/locales/<로케일>/backend.json 으로 올리거나 그 호출을 도메인 밖으로 옮긴다(소유자 판단은 설계 결정).',
|
|
50
55
|
},
|
|
51
56
|
{
|
|
52
57
|
rule: 'response-mixing',
|
|
@@ -98,6 +103,11 @@ export const FIXER_CAPABILITIES = [
|
|
|
98
103
|
hasFixer: false,
|
|
99
104
|
note: '수동 · AGENTS 색인(§0)과 agents/ 실 파일을 일치시킵니다(결정 40).',
|
|
100
105
|
},
|
|
106
|
+
{
|
|
107
|
+
rule: 'redundant-index',
|
|
108
|
+
hasFixer: false,
|
|
109
|
+
note: "수동 · '.index()' 제거 자체는 안전하지만, 원래 의도가 이 컬럼을 선두로 하는 복합 인덱스나 부분 인덱스(.index({ where })) 였을 수 있어 테이블 레벨로 옮길지 지울지는 사람이 판단합니다(결정 460).",
|
|
110
|
+
},
|
|
101
111
|
{
|
|
102
112
|
rule: 'column-casing',
|
|
103
113
|
hasFixer: false,
|
|
@@ -39,8 +39,23 @@ export interface RefEdit {
|
|
|
39
39
|
readonly before: string;
|
|
40
40
|
readonly after: string;
|
|
41
41
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* 파일 제거 계획(결정 459) — 옮길 곳이 **여러 개**라 rename 으로 표현할 수 없는 이관의
|
|
44
|
+
* 마지막 단계다(루트 공용 frontend 는 전 앱으로 복제되므로 1:1 이동이 아니다).
|
|
45
|
+
* 원본은 지우기 전에 `<파일>.bak-<타임스탬프>` 로 백업되므로 되돌릴 수 있다.
|
|
46
|
+
* 복제(rewrite) 계획들 **뒤에** 오도록 fixer 가 순서를 보장한다.
|
|
47
|
+
*/
|
|
48
|
+
export interface RemoveFixerPlan {
|
|
49
|
+
readonly kind: 'remove';
|
|
50
|
+
/** 지울 경로(프로젝트 상대). */
|
|
51
|
+
readonly file: string;
|
|
52
|
+
/** 지우기 전 원본(백업 · rollback 참조용). */
|
|
53
|
+
readonly before: string;
|
|
54
|
+
/** 사람용 요약(어디로 옮겼기에 지우는지). */
|
|
55
|
+
readonly summary: string;
|
|
56
|
+
}
|
|
57
|
+
/** fixer 가 낸 계획 — 내용 재작성 · 파일 이동 · 파일 제거. */
|
|
58
|
+
export type FixerPlan = RewriteFixerPlan | RenameFixerPlan | RemoveFixerPlan;
|
|
44
59
|
/**
|
|
45
60
|
* 규칙 검사가 낸 DoctorCheck 를 받아 파일별 수정 계획을 낸다. 파일 I/O 는
|
|
46
61
|
* 여기서 하지 않는다(테스트 편의 · 순수 함수). 아무 것도 못 고치면 빈
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { RuleReport } from './types.js';
|
|
2
|
+
export declare const T_CALL: RegExp;
|
|
2
3
|
/**
|
|
3
4
|
* 주석을 공백으로 지운다(문자열 리터럴 안의 `//`·`/*` 는 보존). 스캐폴드·문서 주석이
|
|
4
5
|
* 예시로 적은 `t('키')` 를 실 호출로 오인해 **오탐 에러**를 내던 것을 막는다(브라우저 e2e
|
|
5
6
|
* 게이트가 실제로 잡았다). 길이를 보존해 라인 번호 계산이 어긋나지 않는다.
|
|
6
7
|
*/
|
|
7
8
|
export declare function stripComments(src: string): string;
|
|
9
|
+
/** 소스 파일을 재귀 수집한다(생성물·의존성 제외). i18n-server-scope 와 공유. */
|
|
10
|
+
export declare function walkSources(dir: string, exts?: readonly string[], out?: string[]): string[];
|
|
8
11
|
export declare function checkI18nAppScope(cwd: string): Promise<RuleReport>;
|