@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/messages-gen.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
// @gaonjs/cli · .gaon 메시지 축 생성기 (결정 158 · 13차 W2 · 결정 454)
|
|
1
|
+
// @gaonjs/cli · .gaon 메시지 축 생성기 (결정 158 · 13차 W2 · 결정 454 · 459)
|
|
2
2
|
//
|
|
3
|
-
// tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 메시지 축.
|
|
3
|
+
// tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 메시지 축. 두 산출을 낸다:
|
|
4
4
|
//
|
|
5
|
-
// 1) `.gaon/messages.d.ts` — **두 갈래 타입 브리지**
|
|
6
|
-
// · `gaonjs/i18n`(서버) = backend ∪
|
|
7
|
-
// · `gaonjs/vue`(클라) = frontend 만(+ 키별 보간 파라미터) → 서버 전용 문구를
|
|
5
|
+
// 1) `.gaon/messages.d.ts` — **두 갈래 타입 브리지**(루트 단일 · 결정 459 O7)
|
|
6
|
+
// · `gaonjs/i18n`(서버) = domain-backend ∪ 전 앱 backend ∪ 전 앱 frontend
|
|
7
|
+
// · `gaonjs/vue`(클라) = 전 앱 frontend 만(+ 키별 보간 파라미터) → 서버 전용 문구를
|
|
8
8
|
// 클라에서 참조하면 컴파일 에러.
|
|
9
|
+
// 유니온이 **프로젝트 전역**인 것은 타협이 아니라 제약이다 — 앱마다 다른 유니온으로
|
|
10
|
+
// `GaonMessages.keys` 를 augment 하면 TS2717(결정 454 실측 · 결정 459 §5.1 로 유효).
|
|
11
|
+
// 앱 경계는 ① 번들 물리 경계 ② doctor(i18n-app-scope·i18n-server-scope)가 강제한다.
|
|
9
12
|
// 2) `apps/<앱>/.gaon/messages.catalog.ts` — **앱별 값 모듈**(routes.manifest.ts 선례).
|
|
10
13
|
// 그 앱의 frontend 카탈로그만 동적 import 로 묶어 vite 가 로케일별 청크로 쪼갠다.
|
|
11
14
|
// backend.json 은 입력에 아예 없다(노출 차단은 번들 경계가 물리적으로 강제).
|
|
15
|
+
// 결정 459: 공용 스코프가 사라져 **로케일당 소스가 정확히 하나**다.
|
|
12
16
|
//
|
|
13
|
-
// @gaonjs/i18n 의 공개 API(
|
|
17
|
+
// @gaonjs/i18n 의 공개 API(loadProjectLocales·renderMessagesDtsSplit 등)만 쓴다.
|
|
14
18
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
19
|
import { dirname, join, relative, sep } from 'node:path';
|
|
16
|
-
import { extractParams, flattenKeys,
|
|
20
|
+
import { extractParams, flattenKeys, loadProjectLocales, projectLanguages, renderMessagesDtsSplit, withPluralBaseKeys, } from '@gaonjs/i18n';
|
|
17
21
|
/** 번역 트리 두 개를 깊은 병합한다(리프 충돌은 로더가 이미 fail-loud). */
|
|
18
22
|
function mergeTrees(a, b) {
|
|
19
23
|
const out = { ...a };
|
|
@@ -36,42 +40,35 @@ function pickBaseLng(langs, baseLng) {
|
|
|
36
40
|
return [...langs].sort()[0];
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
|
-
* locales
|
|
40
|
-
* 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
41
|
-
* i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
43
|
+
* 프로젝트 카탈로그(domain/locales · apps/*/locales)에서 `.gaon/messages.d.ts`(두 갈래)와
|
|
44
|
+
* 앱별 카탈로그 값 모듈을 생성한다. 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
45
|
+
* 비운 채로 둬 키가 string 폴백 — i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
46
|
+
* 생성 여부를 돌려준다.
|
|
42
47
|
*/
|
|
43
|
-
export function generateMessagesDts(
|
|
48
|
+
export function generateMessagesDts(root, out, baseLng, apps = []) {
|
|
49
|
+
const project = loadProjectLocales(root);
|
|
50
|
+
const langs = projectLanguages(project);
|
|
44
51
|
// 결정 414: 카탈로그가 사라졌으면 **옛 생성물을 지운다** — 남겨두면 없어진 키의
|
|
45
52
|
// 유니온이 그대로 살아 t('없어진키') 가 계속 컴파일된다(제거를 못 잡는 사각).
|
|
46
|
-
if (
|
|
53
|
+
if (langs.length === 0) {
|
|
47
54
|
if (existsSync(out))
|
|
48
55
|
rmSync(out, { force: true });
|
|
49
56
|
for (const app of apps)
|
|
50
57
|
removeStale(app.catalogOut);
|
|
51
58
|
return false;
|
|
52
59
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (langs.size === 0) {
|
|
62
|
-
if (existsSync(out))
|
|
63
|
-
rmSync(out, { force: true });
|
|
64
|
-
for (const app of apps)
|
|
65
|
-
removeStale(app.catalogOut);
|
|
66
|
-
return false;
|
|
60
|
+
const base = pickBaseLng(langs, baseLng);
|
|
61
|
+
// 결정 459: 클라 키 = **전 앱 frontend 합집합**(공용 스코프는 폐지됐다). 앱×앱 축을
|
|
62
|
+
// 타입으로 못 나누는 것은 TS2717 제약이라 그대로다 — 경계는 청크 분리와 doctor 가 강제한다.
|
|
63
|
+
let clientTree = {};
|
|
64
|
+
let appBackendTree = {};
|
|
65
|
+
for (const scoped of Object.values(project.apps)) {
|
|
66
|
+
clientTree = mergeTrees(clientTree, translationOf(scoped.frontend, base));
|
|
67
|
+
appBackendTree = mergeTrees(appBackendTree, translationOf(scoped.backend, base));
|
|
67
68
|
}
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
let clientTree = translationOf(scoped.frontend, base);
|
|
72
|
-
for (const res of appResources.values())
|
|
73
|
-
clientTree = mergeTrees(clientTree, translationOf(res, base));
|
|
74
|
-
const serverTree = mergeTrees(translationOf(scoped.backend, base), clientTree);
|
|
69
|
+
// 서버 키 = domain-backend ∪ 전 앱 backend ∪ 전 앱 frontend(결정 459 O5 — 서버는
|
|
70
|
+
// 화면 문구도 볼 수 있다: SSR·로그·관리 스크립트 경로를 좁히지 않는다).
|
|
71
|
+
const serverTree = mergeTrees(mergeTrees(translationOf(project.domain.backend, base), appBackendTree), clientTree);
|
|
75
72
|
const clientKeys = withPluralBaseKeys(flattenKeys(clientTree));
|
|
76
73
|
const serverKeys = withPluralBaseKeys(flattenKeys(serverTree));
|
|
77
74
|
mkdirSync(dirname(out), { recursive: true });
|
|
@@ -81,14 +78,12 @@ export function generateMessagesDts(localesDir, out, baseLng, apps = []) {
|
|
|
81
78
|
clientKeys,
|
|
82
79
|
clientParams: extractParams(clientTree),
|
|
83
80
|
}), 'utf8');
|
|
84
|
-
// 앱별 카탈로그 값 모듈 — frontend 소스가
|
|
81
|
+
// 앱별 카탈로그 값 모듈 — 그 앱에 frontend 소스가 없으면 만들지 않는다.
|
|
85
82
|
const fallback = base ?? 'en';
|
|
86
83
|
for (const app of apps) {
|
|
87
84
|
generateMessagesCatalog({
|
|
88
|
-
localesDir,
|
|
89
85
|
app,
|
|
90
|
-
|
|
91
|
-
frontendLangs: Object.keys(scoped.frontend),
|
|
86
|
+
appScoped: project.apps[app.name],
|
|
92
87
|
fallbackLng: fallback,
|
|
93
88
|
});
|
|
94
89
|
}
|
|
@@ -104,40 +99,33 @@ function importPath(fromDir, target) {
|
|
|
104
99
|
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
105
100
|
}
|
|
106
101
|
/**
|
|
107
|
-
* 결정 454: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가
|
|
102
|
+
* 결정 454·459: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가 **그 앱의 frontend.json 하나**를
|
|
108
103
|
* 동적 import 해 vite 가 로케일별 해시 청크(.js)로 쪼갠다 — `/assets/*` immutable 캐시 대상이며
|
|
109
104
|
* `text/javascript` 라 seal 봉인 대상(application/json)에서 자연 면제된다.
|
|
110
105
|
* 생성 여부를 돌려준다(프론트 카탈로그가 없으면 false + 스테일 제거).
|
|
111
106
|
*/
|
|
112
107
|
export function generateMessagesCatalog(input) {
|
|
113
108
|
const outDir = dirname(input.app.catalogOut);
|
|
114
|
-
const
|
|
109
|
+
const frontend = input.appScoped?.frontend ?? {};
|
|
110
|
+
const langs = Object.keys(frontend).sort();
|
|
115
111
|
if (langs.length === 0) {
|
|
116
112
|
removeStale(input.app.catalogOut);
|
|
117
113
|
return false;
|
|
118
114
|
}
|
|
119
115
|
const lines = [];
|
|
120
116
|
lines.push('// AUTO-GENERATED by @gaonjs/cli — 편집 금지.');
|
|
121
|
-
lines.push('// `gaon check` / `gaon dev` / `gaon gen` 가 이 파일을 재생성한다(결정 454).');
|
|
122
|
-
lines.push('// 클라이언트 번역 카탈로그: 로케일 → 로더(
|
|
123
|
-
lines.push('// 서버 전용 문구(
|
|
117
|
+
lines.push('// `gaon check` / `gaon dev` / `gaon gen` 가 이 파일을 재생성한다(결정 454 · 459).');
|
|
118
|
+
lines.push('// 클라이언트 번역 카탈로그: 로케일 → 로더(이 앱의 frontend.json).');
|
|
119
|
+
lines.push('// 서버 전용 문구(backend.json)는 여기에 실리지 않는다 — 클라로 나가지 않는다.');
|
|
124
120
|
lines.push("import type { CatalogLoaders } from 'gaonjs/vue'");
|
|
125
121
|
lines.push('');
|
|
126
122
|
lines.push('export const catalogs: CatalogLoaders = {');
|
|
127
123
|
for (const lng of langs) {
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
if (existsSync(
|
|
131
|
-
sources.push(importPath(outDir, shared));
|
|
132
|
-
const appFile = join(input.app.localesDir, `${lng}.json`);
|
|
133
|
-
if (input.appLocales[lng] !== undefined && existsSync(appFile))
|
|
134
|
-
sources.push(importPath(outDir, appFile));
|
|
135
|
-
if (sources.length === 0)
|
|
124
|
+
// 결정 459: 로케일당 소스가 정확히 하나다(공용 스코프 폐지) — 다중 import 분기가 없다.
|
|
125
|
+
const file = join(input.app.localesDir, lng, 'frontend.json');
|
|
126
|
+
if (!existsSync(file))
|
|
136
127
|
continue;
|
|
137
|
-
|
|
138
|
-
? `import('${sources[0]}')`
|
|
139
|
-
: `Promise.all([${sources.map((s) => `import('${s}')`).join(', ')}])`;
|
|
140
|
-
lines.push(` '${lng}': () => ${body},`);
|
|
128
|
+
lines.push(` '${lng}': () => import('${importPath(outDir, file)}'),`);
|
|
141
129
|
}
|
|
142
130
|
lines.push('}');
|
|
143
131
|
lines.push('');
|
package/dist/scaffold/app.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { ScaffoldFile } from './controller.js';
|
|
2
2
|
/** 이름 유효성 — apps/<name>/ 이 안전하고 URL 프리픽스로도 무리 없는 부분집합. */
|
|
3
3
|
export declare function validateAppName(name: string): void;
|
|
4
|
+
/** 앱 스캐폴드 옵션. */
|
|
5
|
+
export interface AppScaffoldOptions {
|
|
6
|
+
/**
|
|
7
|
+
* 결정 459: i18n 을 쓰는 프로젝트의 기준 로케일. 주면 그 앱의 **필수** frontend
|
|
8
|
+
* 카탈로그(`apps/<앱>/locales/<로케일>/frontend.json`)를 함께 만든다. 생략 =
|
|
9
|
+
* i18n 미사용 프로젝트라 카탈로그를 만들지 않는다.
|
|
10
|
+
*/
|
|
11
|
+
readonly fallbackLng?: string;
|
|
12
|
+
}
|
|
4
13
|
/** 앱 스캐폴드 파일 계획. 순수 함수 — 실제 쓰기는 writeScaffold 가 담당. */
|
|
5
|
-
export declare function appScaffoldFiles(name: string): ScaffoldFile[];
|
|
14
|
+
export declare function appScaffoldFiles(name: string, options?: AppScaffoldOptions): ScaffoldFile[];
|
package/dist/scaffold/app.js
CHANGED
|
@@ -47,7 +47,7 @@ export function validateAppName(name) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
/** 앱 스캐폴드 파일 계획. 순수 함수 — 실제 쓰기는 writeScaffold 가 담당. */
|
|
50
|
-
export function appScaffoldFiles(name) {
|
|
50
|
+
export function appScaffoldFiles(name, options = {}) {
|
|
51
51
|
validateAppName(name);
|
|
52
52
|
const files = [];
|
|
53
53
|
// 1) routes.ts — 관례상 GET / + /health 두 라우트로 시작한다.
|
|
@@ -160,7 +160,17 @@ export function appScaffoldFiles(name) {
|
|
|
160
160
|
``,
|
|
161
161
|
].join('\n'),
|
|
162
162
|
});
|
|
163
|
-
// 6
|
|
163
|
+
// 6) locales/<로케일>/frontend.json — 결정 459: **화면 문구의 소유자는 그 앱**이다.
|
|
164
|
+
// frontend 카탈로그는 앱마다 필수라 스캐폴드가 자리를 만들어 둔다(빈 파일이 아니라
|
|
165
|
+
// 실제로 쓰이는 키 하나를 둬야 "여기에 넣으면 되는구나" 가 보인다). 서버 문구가
|
|
166
|
+
// 필요해지면 같은 폴더에 backend.json 을 추가한다(선택).
|
|
167
|
+
if (options.fallbackLng) {
|
|
168
|
+
files.push({
|
|
169
|
+
path: `apps/${name}/locales/${options.fallbackLng}/frontend.json`,
|
|
170
|
+
contents: `${JSON.stringify({ nav: { home: name } }, null, 2)}\n`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// 7-9) 관례 폴더 유지 — git 이 빈 폴더를 추적하지 않아 .gitkeep 을 둔다.
|
|
164
174
|
// components · composables · channels 는 앱이 클수록 자주 채워지는
|
|
165
175
|
// 세 자리다(E-5 §2.2 · M6 실시간).
|
|
166
176
|
for (const sub of ['components', 'composables', 'channels']) {
|
|
@@ -80,7 +80,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
80
80
|
혼용 금지 · doctor response-mixing).
|
|
81
81
|
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`routes.manifest.ts`
|
|
82
82
|
(routes 축 = 타입 브리지 + api() 런타임 매니페스트 2파일 · 결정 127)·
|
|
83
|
-
`tables.d.ts`·`messages.d.ts`(3축 ·
|
|
83
|
+
`tables.d.ts`·`messages.d.ts`(3축 · 카탈로그 있을 때 · 결정 158)·`env.d.ts`
|
|
84
84
|
(`.env` 스캔 · 프론트 앱 · 결정 198) 는 `gaon check`/`gaon dev`/`gaon gen` 이
|
|
85
85
|
재생성한다.
|
|
86
86
|
|
|
@@ -113,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
113
113
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
114
114
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
115
115
|
|
|
116
|
-
### 2.2 `gaon doctor` 검사
|
|
116
|
+
### 2.2 `gaon doctor` 검사 37종
|
|
117
117
|
|
|
118
118
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
119
119
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -141,15 +141,17 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
141
141
|
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
142
142
|
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). **파티션 키 컬럼이 실제 컬럼인지도 검사**(결정 277 · `checkPartitions` — 오타·유령 컬럼). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`·`checkPartitions`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134·277 · `agents/data.md`)
|
|
143
143
|
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)
|
|
144
|
-
27. `locale-parity` —
|
|
144
|
+
27. `locale-parity` — **같은 스코프 안에서 로케일 간** 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(서버) 또는 키 문자열(클라 · 결정 455)을 본다. 비교 단위 3종 — ① `domain/locales` backend ② 앱별 frontend ③ 앱별 backend. **스코프·소유자를 가로지르는 비교는 하지 않는다**(앱 전용 키가 전량 오탐). 복수형 접미사는 base 로 정규화해 비교한다. 로케일이 0·1개면 무소음 (`--json` 은 `detail.missing`·`detail.scope`·`detail.owner` · 결정 216·456·459 · `agents/i18n.md` §9.1)
|
|
145
145
|
28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
|
|
146
146
|
29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), **여러 앱이 한 방을 공유**하는 것이 의도면 정의를 `domain/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나 · **등록 파일은 `apps/<앱>/channels/` 에 그대로**). `shared/channels/` 는 정본이 아니다 — authorize 가 DB 를 보면 domain 값 import 가 필요해 `shared-purity`(#6)와 성립 불가이고, `shared/` 는 props 로만 받는 순수 UI 영역이다(§1-3). 옛 배치가 남아 있으면 같은 규칙이 **경고**로 회수한다 — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (결정 456 · `agents/realtime.md` §2.10)
|
|
147
147
|
30. `channel-instance-authorize` — `instance: true` 채널(파라미터화 채널 · 결정 440)에 `authorize` 가 없음 = **경고**. 인스턴스 채널은 임의 문자열 키로 무한 실행 인스턴스(`/gaon/ws/<이름>/<인스턴스>`)가 열리므로, authorize 가 없으면 누구나 아무 인스턴스에나 입장한다. 매치·스레드처럼 참가자가 정해진 채널이면 `authorize(ctx)` 에서 `ctx.instance` 로 입장을 판정하라 — 공개 관전형(누구나 입장)이 의도면 무시해도 된다(재수출 정의는 대상 모듈을 따라가 판정 · `agents/realtime.md` §2.7)
|
|
148
148
|
31. `agents-docs-stale` — 이 문서(`AGENTS.md`)·`agents/*.md` 사본이 **설치된 gaonjs 템플릿(정본)과 다름** = **경고**(byte 비교). gaonjs 업그레이드 후 관례 문서가 옛 채로 남으면 AI 가 낡은 관례·없는 표면으로 코드를 짠다 — `gaon g agents-docs` 로 재동기하라(미리보기 `--check` · 멱등 · 사본에 직접 적은 내용은 프로젝트 소유 문서(CLAUDE.md)로 옮긴 뒤 — 이 문서들은 프레임웍 정본 사본이다) (결정 449)
|
|
149
149
|
32. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)에 `NODE_ENV` 가 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 이 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 가 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 그 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`) — 모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
|
|
150
150
|
33. `page-fetch` — 세션 앱 `.vue` 가 앱 내부 경로를 raw `fetch('/...')` 로 호출 = **경고**. CSRF 토큰 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청(POST/PUT/PATCH/DELETE)은 403 으로 죽는다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다). JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 `api()`, 폼은 `useForm`, 부득이한 커스텀 전송은 `readCsrfToken()` 탈출구 — 첫 인자가 `/` 로 시작하는 문자열/템플릿 리터럴만 검출(외부 URL·변수 인자 오탐 제외), JWT/API 앱은 REST + fetch 가 정본이라 제외 (결정 453 · `agents/frontend.md` §2)
|
|
151
|
-
34. `i18n-layout` —
|
|
152
|
-
35. `i18n-app-scope` — 앱 코드가 **그 앱 카탈로그에 없는** 클라 `t()` 키를
|
|
151
|
+
34. `i18n-layout` — **옛 카탈로그 배치**가 남아 있음 = **경고**(폐지된 루트 `locales/`·앱 평면 `<로케일>.json`) · **오류**(`domain/locales/<로케일>/frontend.json`·제거된 `i18n.dir` 설정). 결정 459 의 정본 배치는 소유자 2개다 — `domain/locales/<로케일>/backend.json`(도메인 공통 서버 문구) · `apps/<앱>/locales/<로케일>/{frontend,backend}.json`(그 앱 화면·서버 문구). 옛 배치를 그냥 두면 **아무도 읽지 않는 폴더**가 되어 전 화면·전 메일이 키 문자열로 degrade 하므로 조용히 두지 않는다. `gaon doctor --fix` 가 이관한다(루트 backend·레거시 단일 파일 → `domain/locales`, 앱 평면 파일 → 그 앱 `frontend.json`, 루트 공용 frontend → **전 앱 복제 후 원본 제거**). 전 앱 복제는 되돌리기 쉬운 방향이라 자동화하고, 잉여 키 정리·도메인 frontend 재배치는 사람이 판단한다 (결정 454·459 · `agents/i18n.md`)
|
|
152
|
+
35. `i18n-app-scope` — 앱 코드가 **그 앱 카탈로그에 없는** 클라 `t()` 키를 참조, 또는 `shared/` 컴포넌트가 쓰는 키가 **일부 앱에만** 존재 = **오류**. 클라 키 유니온은 프로젝트 전체 frontend 합집합이라(두 앱이 같은 `GaonMessages.keys` 를 다른 유니온으로 augment 하면 TS2717 이고 `gaon check` 는 단일 tsc 프로그램이다 — 앱별 유니온이 구조적으로 불가) 타입만으로는 앱 경계를 못 지킨다. 앱 번들에는 그 앱 카탈로그만 실리므로 다른 앱 전용 키는 런타임에 번역 대신 **키 문자열이 그대로** 보인다. `shared/` 는 어느 앱 번들에도 실릴 수 있어 그 키는 **모든 앱**에 있어야 한다(공용 카탈로그는 폐지됐다 — 결정 459 O1 · 복제 + 이 검사로 강제). 문구는 `apps/<앱>/locales/<로케일>/frontend.json` 에 두고 `gaon gen`. 서버 `t()`(`gaonjs/i18n`) 호출은 `i18n-server-scope` 담당이다 (결정 454·459)
|
|
153
|
+
36. `i18n-server-scope` — 서버 `t()`(`gaonjs/i18n`)가 **소유자 경계**를 넘는 키를 참조 = **오류**. `domain/**` 은 `domain/locales` backend 만, `apps/<앱>/**` 은 그 앱 backend ∪ frontend ∪ domain backend 만 볼 수 있다. 이 유형은 두 층이 모두 놓친다 — 서버 키 유니온은 domain ∪ 전 앱이라 **컴파일을 통과**하고(결정 459 O5), 요청 컨텍스트에서는 앱 네임스페이스가 상속돼 **우연히 해석된다**. 같은 코드가 워커(`gaon work`)·크론에서 불리면 앱 스코프가 없어 키가 비므로 "개발 중엔 되는데 잡에서만 키 문자열이 뜬다" 로 샌다. 결정 455 의 2단 계약(빌드=차단 / 런타임=키 렌더+경고)에서 **빌드=차단의 절반이 무너지는** 지점이라 정적으로 못박는다. 수리: 문구를 `domain/locales/<로케일>/backend.json` 으로 올리거나 그 호출을 도메인 밖으로 옮긴다 (결정 459 · `agents/i18n.md` §3)
|
|
154
|
+
37. `redundant-index` — **unique·PK 컬럼에 붙은 `.index()`** = **경고**. UNIQUE 제약과 PK 는 그 자체가 인덱스라 컬럼 레벨 `.index()` 는 중복이고, DDL 생성기가 정확히 그래서 **건너뛴다**(스키마·DDL 결과는 옳다). 문제는 그 skip 이 조용해 개발자가 no-op 인 줄 모르는 것 — 특히 `.index({ where: ... })`(부분 인덱스)·`.index({ using: 'brin' })`(인덱스 메서드)까지 함께 증발하는데 아무 신호가 없다. 수리: 그 `.index()` 를 지우거나(지워도 결과 동일), 원래 의도가 **그 컬럼을 선두로 하는 복합·부분 인덱스**였다면 테이블 레벨 `table('t', {...}, { index: [['email','teamId']] })` 로 옮긴다. 빌더·타입은 막지 않는다 — 수식어는 어떤 조합·순서든 컴파일되는 것이 의도된 성질이라(`.unique().index()` 포함) 신호는 이 정적 검사로만 준다. `--fix` 없음(지울지 옮길지는 의도 판단 · 결정 460 · `agents/data.md` §3)
|
|
153
155
|
|
|
154
156
|
## 3. 로직 배치 One Way 판단표
|
|
155
157
|
|
|
@@ -210,7 +212,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
210
212
|
```bash
|
|
211
213
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
212
214
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
213
|
-
gaon doctor # 정적 검사
|
|
215
|
+
gaon doctor # 정적 검사 37종 (§2.2)
|
|
214
216
|
```
|
|
215
217
|
|
|
216
218
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -164,6 +164,11 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
164
164
|
- `.index({ where: "status = 'active'" })` — partial(부분) 인덱스.
|
|
165
165
|
- method 집합 = `btree | gin | brin | gist`. **jsonb→gin 자동은 컬럼 `.index()` 한 곳뿐** —
|
|
166
166
|
brin/gist 는 명시(과유도가 perpetual-diff 를 늘림). **`t.jsonb()` 도 `.index()` 를 가진다**(결정 273 신설).
|
|
167
|
+
- **unique·PK 컬럼에는 `.index()` 를 붙이지 않는다**(결정 460). UNIQUE 제약·PK 가 이미
|
|
168
|
+
인덱스라 DDL 이 그 `.index()` 를 **생성하지 않는다** — 옵션(`where`·`using`)까지 함께
|
|
169
|
+
증발한다. 체이닝은 컴파일되므로(수식어는 어떤 조합·순서든 통과) `gaon doctor` 의
|
|
170
|
+
`redundant-index` 가 경고로 알린다. 그 컬럼을 **선두로 하는 복합·부분 인덱스**가
|
|
171
|
+
필요했다면 테이블 레벨 `{ index: [[...]] }` 로 옮긴다.
|
|
167
172
|
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4 · `enum`·`uuid`·`belongsTo`·`bytea`
|
|
168
173
|
제외 — §3.1).
|
|
169
174
|
|
|
@@ -1340,3 +1345,4 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
1340
1345
|
| 결정 336 | 중첩 hidden dot-path 실지원(290 대체) — 중첩 객체·type:{} 서브도큐먼트·서브도큐먼트 배열 · 직렬화 경계 상속 스레딩 · 지원 밖 위치는 fail-loud(§7.1) |
|
|
1341
1346
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
1342
1347
|
| **결정 456** | **수식어 × 빌더 매트릭스(§3.1) + `t.belongsTo(...).unique()` 신설** — "모든 타입에 적용" 이라 적고 조합표가 없어 `t.belongsTo('x').unique()` 가 migrate 를 죽였다(auction 실측). 1:1 FK 는 정당한 도메인 요구라 `unique` 는 **열고**, `default`·`check` 는 참조 무결성과 어긋나 **닫는다**(표에 이유까지 명기 · `type-tests/columnModifiers.test-d.ts` 가 매트릭스를 타입으로 고정) |
|
|
1348
|
+
| 결정 460 | unique·PK 컬럼의 `.index()` 는 DDL 이 건너뛴다(중복) — 조용한 no-op 을 `gaon doctor` `redundant-index` **경고**로 표면화(§3 `.index()`) · 빌더·타입은 무변경(체이닝 자유는 의도된 성질) |
|