@gaonjs/cli 0.38.0 → 0.40.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.
@@ -55,9 +55,9 @@ export const FIXER_CAPABILITIES = [
55
55
  note: '수동 · `gaon db diff` → 리뷰 → `gaon db migrate` (SQL 은 사람이 확인).',
56
56
  },
57
57
  {
58
- rule: 'shared-composable-purity',
58
+ rule: 'shared-purity',
59
59
  hasFixer: false,
60
- note: '수동 · 컴포저블에 든 앱 종속 로직을 앱 쪽으로 옮겨야 합니다(E-5 §2.2).',
60
+ note: '수동 · shared(.ts·.vue)에 든 앱 종속 로직(api/pageProps·domain 값)을 앱 쪽으로 옮기거나 props/인자로 받으세요(E-5 §2.2 · 결정 217).',
61
61
  },
62
62
  {
63
63
  rule: 'no-auto-import',
@@ -0,0 +1,6 @@
1
+ import type { RuleReport } from './types.js';
2
+ /**
3
+ * locales/ 의 로케일 간 키 diff 를 계산해 부분 누락을 경고로 낸다. 로케일이 0·1개면
4
+ * 비교 대상이 없어 건너뛴다(i18n 미사용·단일 로케일 프로젝트는 무소음).
5
+ */
6
+ export declare function checkLocaleParity(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,68 @@
1
+ // @gaonjs/cli · doctor · 로케일 키 부분 누락 검출 (결정 216 · 13차 W4 · 경고)
2
+ //
3
+ // .gaon/messages.d.ts 의 키 유니온은 **기준 로케일 하나**에서 뽑는다(generator.ts ·
4
+ // renderMessagesDts). 그래서 어떤 키가 특정 로케일에만 빠져 있으면 타입 검사는 통과하고,
5
+ // 런타임에 그 로케일 사용자는 fallback 번역(대개 다른 언어)을 조용히 보게 된다 — 타입도
6
+ // 못 잡고 화면에도 티가 안 나는 사각(결정 216). 이 검사가 locales/ 를 훑어 로케일 간 키
7
+ // 집합을 비교해, 다른 로케일엔 있는데 이 로케일엔 없는 키를 파일·키로 경고한다.
8
+ //
9
+ // 경고(error 아님): 부분 누락은 fallback 으로 화면이 깨지진 않는 소프트 결함이라
10
+ // 빌드를 세우지 않는다. --json 은 detail.locale·detail.missing 으로 구조화한다.
11
+ import { existsSync } from 'node:fs';
12
+ import { join, relative } from 'node:path';
13
+ import { loadLocales, flattenKeys } from '@gaonjs/i18n';
14
+ // 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
15
+ const MAX_KEYS_SHOWN = 20;
16
+ // i18next 복수형 접미사(결정 181 · generator.ts 와 동일 규약). 로케일마다 필요한 접미사만
17
+ // 두는 것이 정상이라(en `_one`·`_other` / ko `_other`), 접미사 변형 차이는 부분 누락이
18
+ // 아니다 — base 키로 정규화해 비교해야 정당한 복수형 카탈로그에 오탐(경고)을 안 낸다.
19
+ const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
20
+ /** 키를 복수형 base 로 정규화한다(접미사 제거 · 비복수 키는 그대로). */
21
+ function pluralBase(key) {
22
+ return key.replace(PLURAL_SUFFIX, '');
23
+ }
24
+ /**
25
+ * locales/ 의 로케일 간 키 diff 를 계산해 부분 누락을 경고로 낸다. 로케일이 0·1개면
26
+ * 비교 대상이 없어 건너뛴다(i18n 미사용·단일 로케일 프로젝트는 무소음).
27
+ */
28
+ export async function checkLocaleParity(cwd) {
29
+ const localesDir = join(cwd, 'locales');
30
+ if (!existsSync(localesDir))
31
+ return { rule: 'locale-parity', issues: [] };
32
+ const resources = loadLocales(localesDir);
33
+ const langs = Object.keys(resources).sort();
34
+ if (langs.length < 2)
35
+ return { rule: 'locale-parity', issues: [] };
36
+ // 로케일별 키 집합과 전체 union(어느 로케일에든 등장한 키의 합집합)을 만든다.
37
+ // 복수형 접미사는 base 로 정규화해 로케일별 정당한 복수형 차이를 오탐으로 잡지 않는다.
38
+ const keysByLang = new Map();
39
+ const union = new Set();
40
+ for (const lng of langs) {
41
+ const keys = new Set(flattenKeys(resources[lng].translation).map(pluralBase));
42
+ keysByLang.set(lng, keys);
43
+ for (const k of keys)
44
+ union.add(k);
45
+ }
46
+ const issues = [];
47
+ for (const lng of langs) {
48
+ const have = keysByLang.get(lng);
49
+ const missing = [...union].filter((k) => !have.has(k)).sort();
50
+ if (missing.length === 0)
51
+ continue;
52
+ const rel = relative(cwd, join(localesDir, `${lng}.json`));
53
+ const shown = missing.slice(0, MAX_KEYS_SHOWN);
54
+ const more = missing.length - shown.length;
55
+ const list = shown.map((k) => `'${k}'`).join(', ') + (more > 0 ? ` …외 ${more}개` : '');
56
+ issues.push({
57
+ rule: 'locale-parity',
58
+ level: 'warning',
59
+ file: rel,
60
+ message: `로케일 '${lng}' 에 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel}\n` +
61
+ ` 누락 키: ${list}\n` +
62
+ `→ ${rel} 에 이 키들을 채우세요 — 없으면 '${lng}' 사용자에게 fallback(대개 다른 언어)\n` +
63
+ ` 번역이 조용히 노출됩니다. messages.d.ts 는 기준 로케일 기준이라 컴파일로 못 잡습니다(결정 216).`,
64
+ detail: { locale: lng, missing },
65
+ });
66
+ }
67
+ return { rule: 'locale-parity', issues };
68
+ }
@@ -0,0 +1,9 @@
1
+ import type { DoctorCheck, RuleReport } from './types.js';
2
+ /**
3
+ * 단일 shared 소스(.ts 원문 또는 .vue script 블록)를 검사해 순수성 위반
4
+ * 목록을 낸다(단위 테스트 진입점). file 확장자가 .vue 면 <script> 만 뽑아
5
+ * 파싱한다 — line 번호는 script 블록 기준(안내 정확도보다 위반 유무가 우선).
6
+ */
7
+ export declare function inspectSharedSource(file: string, source: string, cwd: string): DoctorCheck[];
8
+ /** shared/ 전체(.ts · .vue)를 훑어 순수성 위반을 모두 낸다. */
9
+ export declare function checkSharedPurity(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,188 @@
1
+ // @gaonjs/cli · doctor · shared 순수성 검사 (M9-E 확장 · 결정 217 · errata E-5 §2.2)
2
+ //
3
+ // shared/ 는 앱을 몰라야 한다(§5 · CLAUDE.md rule 5). 라우트를 몰라야 하고
4
+ // (api·pageProps 값 import 금지) · domain 은 타입으로만 참조해야 한다. 필요한
5
+ // 데이터·호출 함수는 props/인자로 받는다. 이 규칙은 shared 컴포저블뿐 아니라
6
+ // shared 컴포넌트(.vue)에도 똑같이 적용된다 — 관례가 컴포저블·컴포넌트에 대칭
7
+ // 이므로(결정 25) 검사도 한 규칙으로 통일한다(결정 217).
8
+ //
9
+ // 이력: M9-E 때는 `shared-composable-purity` 라는 이름으로 shared/composables/
10
+ // 의 .ts 만 훑었다. 그러나 shared/components/*.vue 안에서 domain 모델을 값으로
11
+ // import 해도 27 검사가 전부 통과하는 공백이 12차 실사용(support-desk)에서 드러났다
12
+ // (§4.5 잔재). 결정 217: 규칙명을 `shared-purity` 로 개정하고 검사 범위를
13
+ // shared/ 전체(.ts · .vue)로 넓혀 The One Way 로 통일한다.
14
+ //
15
+ // 검사 대상(파일 종류 무관):
16
+ // 1) 프레임웍 모듈(gaonjs · gaonjs/vue · @gaonjs/vue · @gaonjs/web) 에서
17
+ // 'api' · 'pageProps' 를 value import 하면 error.
18
+ // - type-only 는 무해(구조 참조뿐 · 라우트 지식 필요 없음).
19
+ // 2) domain 을 value import 하면 error.
20
+ // - type-only 는 허용(모델 Row 타입 등 · 순수 타입 참조).
21
+ //
22
+ // 방법: shared/ 아래 .ts 는 소스 그대로, .vue 는 <script> 블록만 뽑아 TS AST 로
23
+ // 파싱해 import 선언만 훑는다. 상대 import 는 실 파일까지 해석하지 않고 경로
24
+ // 접두사(domain/) 로 판정 — 이 검사는 순수성 게이트라 정확도보다 재현성이 우선
25
+ // (false positive 는 오히려 안전).
26
+ import { readdir, readFile } from 'node:fs/promises';
27
+ import { join, relative, resolve, dirname } from 'node:path';
28
+ import ts from 'typescript';
29
+ /** 라우트 지식을 담은 심볼 — shared 는 참조할 수 없다. */
30
+ const FORBIDDEN_FRAMEWORK_NAMES = new Set(['api', 'pageProps']);
31
+ /**
32
+ * 프레임웍 모듈 패턴. 문서 표기(gaon/vue)는 실 패키지 이름의 짧은
33
+ * 별칭이며, 실 배포본은 gaonjs 파사드 subpath 와 @gaonjs 스코프를 쓴다.
34
+ * 하나만 잡으면 우회가 쉬우므로 알려진 표기 3종을 모두 매치한다.
35
+ */
36
+ const FRAMEWORK_MODULE_PATTERNS = [
37
+ /^gaonjs$/,
38
+ /^gaonjs\/vue$/,
39
+ /^@gaonjs\/vue$/,
40
+ /^@gaonjs\/web$/,
41
+ ];
42
+ /** import 선언에서 이름과 type-only 플래그를 추출한다. */
43
+ function collectImportedNames(node) {
44
+ const out = [];
45
+ const clause = node.importClause;
46
+ if (!clause)
47
+ return out;
48
+ const clauseTypeOnly = clause.isTypeOnly;
49
+ if (clause.name)
50
+ out.push({ name: clause.name.text, typeOnly: clauseTypeOnly });
51
+ const bindings = clause.namedBindings;
52
+ if (bindings) {
53
+ if (ts.isNamespaceImport(bindings)) {
54
+ out.push({ name: bindings.name.text, typeOnly: clauseTypeOnly });
55
+ }
56
+ else if (ts.isNamedImports(bindings)) {
57
+ for (const el of bindings.elements) {
58
+ out.push({ name: el.name.text, typeOnly: clauseTypeOnly || el.isTypeOnly });
59
+ }
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function isRelativeSpecifier(s) {
65
+ return s.startsWith('./') || s.startsWith('../');
66
+ }
67
+ function matchesFrameworkModule(spec) {
68
+ return FRAMEWORK_MODULE_PATTERNS.some((re) => re.test(spec));
69
+ }
70
+ /**
71
+ * .vue SFC 에서 <script>·<script setup> 블록 안 텍스트만 뽑아 하나로 잇는다.
72
+ * doctor 는 import 선언만 보므로 script 밖(<template>·<style>)은 무시한다.
73
+ * 두 블록(일반+setup)이 다 있을 수 있어 전부 이어 붙인다.
74
+ */
75
+ function extractVueScript(source) {
76
+ const blocks = [];
77
+ const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
78
+ let m;
79
+ while ((m = re.exec(source)) !== null)
80
+ blocks.push(m[1] ?? '');
81
+ return blocks.join('\n');
82
+ }
83
+ /**
84
+ * 단일 shared 소스(.ts 원문 또는 .vue script 블록)를 검사해 순수성 위반
85
+ * 목록을 낸다(단위 테스트 진입점). file 확장자가 .vue 면 <script> 만 뽑아
86
+ * 파싱한다 — line 번호는 script 블록 기준(안내 정확도보다 위반 유무가 우선).
87
+ */
88
+ export function inspectSharedSource(file, source, cwd) {
89
+ const issues = [];
90
+ const rel = relative(cwd, file);
91
+ const code = file.endsWith('.vue') ? extractVueScript(source) : source;
92
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.ES2022, true);
93
+ const baseDir = dirname(file);
94
+ const visit = (node) => {
95
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
96
+ const spec = node.moduleSpecifier.text;
97
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
98
+ const names = collectImportedNames(node);
99
+ // 1) 프레임웍의 api/pageProps 를 value import → error
100
+ if (matchesFrameworkModule(spec)) {
101
+ for (const n of names) {
102
+ if (n.typeOnly)
103
+ continue; // type-only 는 라우트 실행 지식이 아님
104
+ if (!FORBIDDEN_FRAMEWORK_NAMES.has(n.name))
105
+ continue;
106
+ issues.push({
107
+ rule: 'shared-purity',
108
+ level: 'error',
109
+ file: rel,
110
+ line: line + 1,
111
+ message: `${rel} (line ${line + 1})\n` +
112
+ ` '${spec}' 의 '${n.name}' import 발견 · shared 는 라우트를 몰라야 함\n` +
113
+ `→ 옵션: apps/<앱>/ 로 옮기거나 · ${n.name} 결과를 props/인자로 받도록 바꾸라`,
114
+ detail: {
115
+ kind: 'framework-api',
116
+ module: spec,
117
+ name: n.name,
118
+ },
119
+ });
120
+ }
121
+ }
122
+ // 2) domain value import → error (type-only 는 허용)
123
+ if (isRelativeSpecifier(spec)) {
124
+ const abs = resolve(baseDir, spec);
125
+ const parts = relative(cwd, abs).split(/[\\/]/).filter(Boolean);
126
+ if (parts[0] === 'domain') {
127
+ const valueNames = names.filter((n) => !n.typeOnly);
128
+ if (valueNames.length > 0) {
129
+ issues.push({
130
+ rule: 'shared-purity',
131
+ level: 'error',
132
+ file: rel,
133
+ line: line + 1,
134
+ message: `${rel} (line ${line + 1})\n` +
135
+ ` domain 값 import 발견 · shared 는 domain 을 타입으로만 참조해야 함\n` +
136
+ `→ 'import type { ... } from ...' 형태로 바꾸거나 · 필요한 값은 props/인자로 받도록 바꾸라`,
137
+ detail: {
138
+ kind: 'domain-value',
139
+ module: spec,
140
+ names: valueNames.map((n) => n.name),
141
+ },
142
+ });
143
+ }
144
+ }
145
+ }
146
+ }
147
+ ts.forEachChild(node, visit);
148
+ };
149
+ visit(sf);
150
+ return issues;
151
+ }
152
+ /** shared/ 전체(.ts · .vue)를 훑어 순수성 위반을 모두 낸다. */
153
+ export async function checkSharedPurity(cwd) {
154
+ const sharedDir = join(cwd, 'shared');
155
+ const files = [];
156
+ await collectSharedFiles(sharedDir, files);
157
+ const issues = [];
158
+ for (const file of files) {
159
+ const src = await readFile(file, 'utf8');
160
+ issues.push(...inspectSharedSource(file, src, cwd));
161
+ }
162
+ return { rule: 'shared-purity', issues };
163
+ }
164
+ async function collectSharedFiles(root, out) {
165
+ let entries;
166
+ try {
167
+ entries = (await readdir(root, { withFileTypes: true }));
168
+ }
169
+ catch {
170
+ return;
171
+ }
172
+ for (const e of entries) {
173
+ const name = e.name;
174
+ if (name === 'node_modules' || name === 'dist' || name === '.gaon')
175
+ continue;
176
+ const full = join(root, name);
177
+ if (e.isDirectory())
178
+ await collectSharedFiles(full, out);
179
+ else if (e.isFile() && isScannable(name))
180
+ out.push(full);
181
+ }
182
+ }
183
+ /** shared 순수성 검사 대상 파일인가 — .ts(선언·테스트 제외) 또는 .vue. */
184
+ function isScannable(name) {
185
+ if (name.endsWith('.vue'))
186
+ return true;
187
+ return name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts');
188
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -7,7 +7,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
7
7
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
8
8
  export { checkSchemaRelations } from './doctor/schema-relations.js';
9
9
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
10
- export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
10
+ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
11
11
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
12
12
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
13
13
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
@@ -22,13 +22,14 @@ export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/p
22
22
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
23
23
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
24
24
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
25
+ export { checkLocaleParity } from './doctor/locale-parity.js';
25
26
  export { renderHuman, renderJson } from './doctor/reporter.js';
26
27
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
27
28
  /**
28
- * 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
29
+ * 실행할 검사 이름. 지정 없음(undefined) = 27개 모두.
29
30
  */
30
31
  /**
31
- * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
32
+ * doctor 정적 검사 27종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
32
33
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
33
34
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
34
35
  */
package/dist/doctor.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 26 검사를 조립한다:
4
+ * 27 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
8
8
  * 4) connections (v0.15 §4.5)
9
9
  * 5) migration-diff (최소 감지 · 상세는 M9-D)
10
- * 6) shared-composable-purity (errata E-5 §2.2 · 결정 25)
10
+ * 6) shared-purity (errata E-5 §2.2 · 결정 25·217 · shared/ 전체 .ts·.vue)
11
11
  * 7) no-auto-import (errata E-5 §2.4 · v0.15 §1.2)
12
12
  * 8) schema-filename (§1.1 · 결정 38 · 스키마 파일명 camelCase)
13
13
  * 9) agents-doc-index (§0 색인 ↔ agents/ 실 파일 · 결정 40)
@@ -28,6 +28,7 @@
28
28
  * 24) seal-security (결정 121 · seal 클라 배선 · 보안 역전)
29
29
  * 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
30
30
  * 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
31
+ * 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
31
32
  *
32
33
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
33
34
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -46,7 +47,7 @@ import { checkDependencyDirection } from './doctor/dependency-direction.js';
46
47
  import { checkConnections } from './doctor/connections.js';
47
48
  import { checkSchemaRelations } from './doctor/schema-relations.js';
48
49
  import { checkMigrationDiff } from './doctor/migration-diff.js';
49
- import { checkSharedComposablePurity } from './doctor/shared-composable-purity.js';
50
+ import { checkSharedPurity } from './doctor/shared-purity.js';
50
51
  import { checkNoAutoImport } from './doctor/no-auto-import.js';
51
52
  import { checkSchemaFilename } from './doctor/schema-filename.js';
52
53
  import { checkAgentsDocIndex } from './doctor/agents-doc-index.js';
@@ -66,6 +67,7 @@ import { checkSealSecurity } from './doctor/seal-security.js';
66
67
  import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
67
68
  import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
68
69
  import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
70
+ import { checkLocaleParity } from './doctor/locale-parity.js';
69
71
  import { renderHuman, renderJson } from './doctor/reporter.js';
70
72
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
71
73
  import { makeResult, } from './doctor/types.js';
@@ -76,7 +78,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
76
78
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
77
79
  export { checkSchemaRelations } from './doctor/schema-relations.js';
78
80
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
79
- export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
81
+ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
80
82
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
81
83
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
82
84
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
@@ -91,13 +93,14 @@ export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/p
91
93
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
92
94
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
93
95
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
96
+ export { checkLocaleParity } from './doctor/locale-parity.js';
94
97
  export { renderHuman, renderJson } from './doctor/reporter.js';
95
98
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
96
99
  /**
97
- * 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
100
+ * 실행할 검사 이름. 지정 없음(undefined) = 27개 모두.
98
101
  */
99
102
  /**
100
- * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
103
+ * doctor 정적 검사 27종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
101
104
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
102
105
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
103
106
  */
@@ -107,7 +110,7 @@ export const ALL_RULES = [
107
110
  'dependency-direction',
108
111
  'connections',
109
112
  'migration-diff',
110
- 'shared-composable-purity',
113
+ 'shared-purity',
111
114
  'no-auto-import',
112
115
  'schema-filename',
113
116
  'agents-doc-index',
@@ -128,6 +131,7 @@ export const ALL_RULES = [
128
131
  'seal-security',
129
132
  'schema-relations',
130
133
  'no-import-meta-env',
134
+ 'locale-parity',
131
135
  ];
132
136
  const CHECKERS = {
133
137
  'response-mixing': checkResponseMixing,
@@ -135,7 +139,7 @@ const CHECKERS = {
135
139
  'dependency-direction': checkDependencyDirection,
136
140
  connections: checkConnections,
137
141
  'migration-diff': checkMigrationDiff,
138
- 'shared-composable-purity': checkSharedComposablePurity,
142
+ 'shared-purity': checkSharedPurity,
139
143
  'no-auto-import': checkNoAutoImport,
140
144
  'schema-filename': checkSchemaFilename,
141
145
  'agents-doc-index': checkAgentsDocIndex,
@@ -156,6 +160,7 @@ const CHECKERS = {
156
160
  'seal-security': checkSealSecurity,
157
161
  'schema-relations': checkSchemaRelations,
158
162
  'no-import-meta-env': checkNoImportMetaEnv,
163
+ 'locale-parity': checkLocaleParity,
159
164
  };
160
165
  /**
161
166
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/generate.js CHANGED
@@ -33,6 +33,20 @@ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates',
33
33
  function urlPrefixFor(app) {
34
34
  return app === 'web' ? '' : `/${app}`;
35
35
  }
36
+ /**
37
+ * 결정 219: `~ 패치` 라벨을 파일별 실제 편집 내용으로 정확히 붙인다. patchEnvFiles·
38
+ * patchAppConfigAddAuth·patchRoutes 가 서로 다른 편집을 하는데 예전엔 전부 "라우트 추가"
39
+ * 로 라벨해 .env·app.config 편집을 오도했다(scaffold 로그 정확성).
40
+ */
41
+ function patchLabel(rel) {
42
+ if (rel === '.env' || rel === '.env.example')
43
+ return '세션 secret 추가';
44
+ if (rel.endsWith('/app.config.ts') || rel === 'app.config.ts')
45
+ return '인증 배선 추가';
46
+ if (rel.endsWith('/routes.ts') || rel === 'routes.ts')
47
+ return '라우트 추가';
48
+ return '수정';
49
+ }
36
50
  /**
37
51
  * 앱의 세션 secret 환경변수명 — web 은 'SESSION_SECRET'(기존), 그 외는
38
52
  * '<APP>_SESSION_SECRET'(결정 141 · 앱별 세션 완전 분리 · session.ts 는 앱마다
@@ -99,6 +113,9 @@ function renderTemplate(name, app, includePublic) {
99
113
  .replaceAll('{{APP_NAME}}', app)
100
114
  .replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
101
115
  .replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
116
+ // 결정 218: 시큐어 세션 컨트롤러의 로그인 역할 게이트 기준. 결정 155·145 의 대시보드
117
+ // 역할 게이트(role === 'admin')와 같은 값으로 고정해 두 층이 어긋나지 않게 한다.
118
+ .replaceAll('{{REQUIRED_ROLE}}', 'admin')
102
119
  .replaceAll('{{SIGNUP_LINK}}', includePublic ? signupLinkMarkup(app) : '');
103
120
  }
104
121
  /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
@@ -110,7 +127,13 @@ export function authScaffoldFiles(opts = {}) {
110
127
  { tpl: 'user.schema.ts.tpl', out: 'domain/schema/users.ts' },
111
128
  { tpl: 'user.model.ts.tpl', out: 'domain/models/User.ts' },
112
129
  { tpl: 'auth.wiring.ts.tpl', out: `apps/${app}/auth.ts` },
113
- { tpl: 'session.controller.ts.tpl', out: `apps/${app}/controllers/session.ts` },
130
+ // 결정 218: 시큐어 앱은 로그인 시점에 역할을 검증하는 세션 컨트롤러를 깐다 — 고객이
131
+ // 인증만 통과해 세션을 얻은 뒤 대시보드에서 403 을 만나는 rough UX 를 진입점에서 막는다.
132
+ // 공개 앱(web 등)은 역할 개념이 없어 표준 세션 컨트롤러(로그인 = 세션 생성)를 쓴다.
133
+ {
134
+ tpl: includePublic ? 'session.controller.ts.tpl' : 'session.secure.controller.ts.tpl',
135
+ out: `apps/${app}/controllers/session.ts`,
136
+ },
114
137
  // 결정 155: 시큐어(비-web · 비-public) 앱은 역할 게이트 대시보드, 공개 앱은 requireAuth 대시보드.
115
138
  {
116
139
  tpl: includePublic ? 'dashboard.controller.ts.tpl' : 'dashboard.secure.controller.ts.tpl',
@@ -266,14 +289,16 @@ export function writeAuthScaffold(cwd, opts = {}) {
266
289
  // 결정 155·142(W3): 앱별 세션 secret 을 .env·.env.example 에 시드한다. web 은 이미
267
290
  // 있어 멱등, 비-web 앱은 여기서 키가 심어져 "공개 고정 dev secret 폴백" 표면을 막는다.
268
291
  patched.push(...patchEnvFiles(root, app));
269
- // 결정 155: 시큐어 앱(공개 가입 미생성)은 역할 게이트 대시보드를 깔았다 — role 컬럼을
270
- // 두라고 안내한다(§7.5.3 · authorize 예시가 실제 역할 규칙이 되도록).
292
+ // 결정 155·218: 시큐어 앱(공개 가입 미생성)은 로그인 시점 역할 게이트 세션 컨트롤러 +
293
+ // 역할 게이트 대시보드를 깔았다 role 컬럼이 있어야 동작하므로 추가를 안내한다
294
+ // (§7.5.3 · authorize·로그인 게이트 예시가 실제 역할 규칙이 되도록).
271
295
  if (!includePublic) {
272
- warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155).\n` +
296
+ warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155·218).\n` +
273
297
  `→ 관리자는 직접 만들거나 승격하세요(공개 가입 라우트 없음). 공개 가입이 필요하면 --public 로 다시 생성.\n` +
274
298
  `→ 역할 인가를 완성하려면 domain/schema/users.ts 에 role 컬럼을 추가하세요:\n` +
275
299
  ` role: t.string().default('user'),\n` +
276
- ` 그러면 apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트가 실제 역할로 동작합니다.`);
300
+ ` 그러면 로그인(controllers/session.ts)이 역할 부족 세션을 만들지 않고,\n` +
301
+ ` apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트도 실제 역할로 동작합니다.`);
277
302
  }
278
303
  return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
279
304
  }
@@ -292,8 +317,10 @@ export function runGenerateAuthCommand(opts = {}) {
292
317
  lines.push('');
293
318
  for (const f of result.created)
294
319
  lines.push(` + ${f}`);
320
+ // 결정 219: 패치 대상은 파일마다 편집 내용이 다르다 — routes.ts(라우트) · app.config.ts(인증
321
+ // 배선) · .env(세션 secret). 전부 "라우트 추가" 로 라벨하면 .env·app.config 편집을 오도한다.
295
322
  for (const f of result.patched)
296
- lines.push(` ~ ${f} (라우트 추가)`);
323
+ lines.push(` ~ ${f} (${patchLabel(f)})`);
297
324
  for (const f of result.skipped)
298
325
  lines.push(` · ${f} (이미 있음 — 건너뜀)`);
299
326
  for (const w of result.warnings)
package/dist/index.js CHANGED
@@ -109,7 +109,7 @@ function renderHelp(version = VERSION) {
109
109
  " gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
110
110
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
111
111
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
112
- " gaon doctor 정적 검사 (25 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계)",
112
+ " gaon doctor 정적 검사 (27 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지)",
113
113
  " gaon doctor --json 자동화용 JSON 출력",
114
114
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
115
115
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -146,7 +146,7 @@ function renderHelp(version = VERSION) {
146
146
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
147
147
  */
148
148
  export function parseDoctorChecks(argv) {
149
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 26종)를 단일 출처로 쓴다 — 과거
149
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 27종)를 단일 출처로 쓴다 — 과거
150
150
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
151
151
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
152
152
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -307,7 +307,7 @@ export function runCli(argv, opts = {}) {
307
307
  });
308
308
  return;
309
309
  }
310
- // `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
310
+ // `gaon doctor` — 정적 검사(M9-E · 27 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
311
311
  // 선택 실행, --json 은 자동화 파싱용.
312
312
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
313
313
  if (argv[0] === "doctor") {
@@ -0,0 +1,46 @@
1
+ // 세션 컨트롤러(로그인/로그아웃 · 관리 앱) — gaon g auth --app {{APP_NAME}}.
2
+ // 시큐어 앱은 로그인 **시점**에 역할을 검증한다(결정 218). 인증(비밀번호)만 통과한
3
+ // 고객이 세션을 얻은 뒤 대시보드에서야 403 을 만나는 rough UX 를 진입점에서 막는다:
4
+ // · 자격 증명이 틀리면 → 인증 실패(401 성격) · 세션 안 만듦 · 재렌더
5
+ // · 자격은 맞지만 역할 부족 → 인가 실패(403 성격) · 세션 안 만듦 · 재렌더
6
+ // 401 vs 403 을 메시지로 구분한다 — 어느 쪽도 세션을 만들지 않는다.
7
+ import { controller, verifyPassword } from 'gaonjs/web'
8
+ import { User } from '../../../domain/models/User.js'
9
+
10
+ // 이 앱에 로그인할 수 있는 역할. domain/schema/users.ts 에 role 컬럼을 두고
11
+ // (예: role: t.string().default('user')) 관리자에게 '{{REQUIRED_ROLE}}' 을 부여하세요.
12
+ const REQUIRED_ROLE = '{{REQUIRED_ROLE}}'
13
+
14
+ export default controller({
15
+ // GET {{URL_PREFIX}}/session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
16
+ async new() {
17
+ return this.render('Auth/Login', { error: null as string | null })
18
+ },
19
+ // POST {{URL_PREFIX}}/session — 로그인 + 역할 게이트
20
+ async create() {
21
+ const { email, password } = this.params({ _row: {} as { email: string; password: string } })
22
+ const user = await User.where('email', '=', email).first()
23
+ // 인증(401 성격): 자격 증명이 틀리면 세션을 만들지 않고 같은 폼을 재렌더한다.
24
+ // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
25
+ if (!user || !(await verifyPassword(password, user.passwordDigest))) {
26
+ return this.render('Auth/Login', {
27
+ error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
28
+ })
29
+ }
30
+ // 인가(403 성격): 자격은 맞지만 역할이 부족하면 세션을 만들지 않고 재렌더한다.
31
+ // 고객이 "로그인은 됐는데 403" 을 겪지 않도록 진입점에서 막는다(결정 218 · 결정 155·145 의
32
+ // 대시보드 역할 게이트와 정합 — 여기서 세션 자체를 안 만들어 관리 앱을 깨끗이 격리).
33
+ if ((user as { role?: string }).role !== REQUIRED_ROLE) {
34
+ return this.render('Auth/Login', {
35
+ error: '이 앱에 접근할 권한이 없습니다.' as string | null,
36
+ })
37
+ }
38
+ this.auth.login(user)
39
+ return this.redirect('{{URL_PREFIX}}/dashboard')
40
+ },
41
+ // DELETE {{URL_PREFIX}}/session — 로그아웃
42
+ async destroy() {
43
+ this.auth.logout()
44
+ return this.redirect('{{URL_PREFIX}}/session/new')
45
+ },
46
+ })
@@ -108,14 +108,14 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
108
108
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
109
109
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
110
110
 
111
- ### 2.2 `gaon doctor` 검사 26
111
+ ### 2.2 `gaon doctor` 검사 27
112
112
 
113
113
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
114
114
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
115
115
  3. `dependency-direction` — 의존 방향 4규칙 위반
116
116
  4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
117
117
  5. `migration-diff` — 스키마 vs DB 상태 불일치
118
- 6. `shared-composable-purity` — shared `api`/`pageProps` import (결정 25)
118
+ 6. `shared-purity` — shared/ 전체(.ts·.vue)가 `api`/`pageProps`·domain 값을 import (컴포저블·컴포넌트 통일 · 결정 25·217)
119
119
  7. `no-auto-import` — 자동 import 설정 (E-5 §2.4)
120
120
  8. `schema-filename` — 스키마 파일명 camelCase 관례 (결정 38 · `--fix` 지원)
121
121
  9. `agents-doc-index` — 이 문서 색인(§0) ↔ `agents/` 실 파일 불일치 (결정 40)
@@ -136,6 +136,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
136
136
  24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon check --fix` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
137
137
  25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
138
138
  26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
139
+ 27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
139
140
 
140
141
  ## 3. 로직 배치 One Way 판단표
141
142
 
@@ -196,7 +197,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
196
197
  ```bash
197
198
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
198
199
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
199
- gaon doctor # 정적 검사 26종 (§2.2)
200
+ gaon doctor # 정적 검사 27종 (§2.2)
200
201
  ```
201
202
 
202
203
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -50,6 +50,9 @@ const props = pageProps<'web:posts#index'>()
50
50
  // <template> 에서 shared.currentUser?.name · shared.csrf · shared.flash.success
51
51
  // 앱이 app.config sharedProps 로 등록한 키(locale·theme 등)도 같은 자리에서 읽힌다(결정 150).
52
52
  ```
53
+ **i18n 문구도 이 경로로 온다** — Vue 는 `t()` 를 직접 부르지 않는다(서버 ALS 전용).
54
+ 페이지 문구는 컨트롤러 render props, nav·레이아웃 같은 앱 chrome 문구는 `sharedProps`
55
+ 로 서버가 번역해 흘려보낸다(`agents/i18n.md` §5 · 결정 213).
53
56
  `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
54
57
  `useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
55
58
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
@@ -150,15 +153,17 @@ shared/composables/useDebounce.ts # 앱 간 공용 컴포저블 (순수 로
150
153
  | 컴포넌트 | 자유 (pageProps·api 사용 가능) | props 로만 받는 순수 UI |
151
154
  | 컴포저블 | 자유 (api·채널 래핑 가능) | 인자로만 받는 순수 로직 |
152
155
 
153
- **shared 컴포저블 제약:**
156
+ **shared 순수성 제약 (컴포저블·컴포넌트 공통):**
154
157
 
155
158
  - **라우트를 몰라야 한다** — `api()`·`pageProps()` 호출 금지.
156
159
  `.gaon/routes.d.ts` 는 앱별 생성이므로 구조적으로도 불가능하다.
157
- - 필요한 데이터·호출 함수는 **인자로 받는다** (순수 로직).
160
+ - 필요한 데이터·호출 함수는 **props/인자로 받는다** (순수 로직·순수 UI).
158
161
  - domain 은 **타입 import 만** 허용.
159
162
 
160
- `gaon doctor` 의 **shared-composable-purity** 검사가 shared 안에서
161
- `gaonjs/vue` 의 `api`/`pageProps` import 잡는다.
163
+ `gaon doctor` 의 **shared-purity** 검사가 `shared/` 전체(`.ts` 컴포저블 +
164
+ `.vue` 컴포넌트)에서 `gaonjs/vue` 의 `api`/`pageProps` import domain
165
+ 값 import 를 잡는다 (결정 217 — 컴포저블·컴포넌트 한 규칙으로 통일 · `.vue` 는
166
+ `<script>` 블록을 훑는다).
162
167
 
163
168
  **shared 컴포넌트(UI 킷 §8)의 허용/금지도 같은 기준(결정 25·105):** 허용 = 라우트 키와
164
169
  무관한 범용 API(`useForm`·`Link`·`router`) · 금지 = 앱 라우트 지식(`api()`·`pageProps`).
@@ -392,8 +397,8 @@ async function runSearch(q: string) {
392
397
  경계 우회로 타입 안전 붕괴.
393
398
  - **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
394
399
  결정한다.
395
- - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출 금지** — doctor
396
- shared-composable-purity 위반. 데이터는 props/인자로.
400
+ - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
401
+ doctor **shared-purity** 위반(`.ts`·`.vue` 공통 · 결정 217). 데이터는 props/인자로.
397
402
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
398
403
  `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
399
404
  - **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
@@ -457,6 +462,7 @@ async function runSearch(q: string) {
457
462
  | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
458
463
  | 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
459
464
  | 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
465
+ | 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
460
466
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
461
467
 
462
468
  ## `@gaonjs/seal` 켠 앱의 프론트
@@ -96,6 +96,112 @@ async setLocale() {
96
96
  카탈로그에 없는 키·오타가 `gaon check` 에서 걸린다(카탈로그가 없으면 키는
97
97
  `string` 폴백). 이 파일은 자동 생성이니 직접 수정하지 않는다.
98
98
 
99
+ ### 5. Vue 페이지 소비 — 서버가 번역해 흘려보낸다 (결정 213)
100
+
101
+ **`t()` 는 서버 전용이다** — 요청 컨텍스트(ALS)의 로케일을 읽으므로 `gaonjs/vue`
102
+ 는 `t()`·`useT()` 를 **내보내지 않는다**(설계 의도). Vue 페이지·컴포넌트는 번역을
103
+ 직접 하지 않고, **서버에서 번역한 문자열을 받아 쓴다.** 번역 소스가 서버 하나로
104
+ 고정되므로(The One Way) 카탈로그가 클라 번들에 중복 실리지 않고, 키 타입 검사
105
+ (§4)도 서버 한 곳에서만 성립한다. 경로는 두 가지다:
106
+
107
+ **(a) 페이지 문구 = 컨트롤러 render props.** 그 페이지에서만 쓰는 문구는 컨트롤러가
108
+ `t()` 로 번역해 `this.render` props 로 넘긴다 — 페이지는 이미 현지화된 문자열을 받는다.
109
+
110
+ ```ts
111
+ // apps/web/controllers/posts.ts
112
+ import { t } from 'gaonjs/i18n'
113
+ async index() {
114
+ return this.render('Posts/Index', {
115
+ heading: t('posts.heading'), // 서버가 요청 로케일로 번역
116
+ empty: t('posts.empty'),
117
+ posts: (await Post.latest().all()).map((p) => ({ id: String(p.id), title: p.title })),
118
+ })
119
+ }
120
+ ```
121
+
122
+ ```vue
123
+ <!-- apps/web/pages/Posts/Index.vue -->
124
+ <script setup lang="ts">
125
+ import { pageProps } from 'gaonjs/vue'
126
+ const props = pageProps<'web:posts#index'>() // heading·empty 가 현지화된 채로 온다
127
+ </script>
128
+ <template>
129
+ <h1>{{ props.heading }}</h1>
130
+ <p v-if="!props.posts.length">{{ props.empty }}</p>
131
+ </template>
132
+ ```
133
+
134
+ **(b) 앱 chrome(전 페이지 공용 문구) = `app.config.ts` 의 `sharedProps` (결정 150 동형).**
135
+ nav 라벨·레이아웃 문구처럼 앱의 **모든** 페이지가 쓰는 chrome 문구는 컨트롤러마다
136
+ 넘기지 않고 `sharedProps` 로 한 번 등록한다 — `useShared()` 로 라우트 키 없이 읽힌다
137
+ (레이아웃·공용 컴포넌트에서 특히 유용 · `agents/frontend.md` §1·web.md §4.2).
138
+
139
+ ```ts
140
+ // apps/web/app.config.ts
141
+ import { t } from 'gaonjs/i18n'
142
+ export default defineAppConfig({
143
+ sharedProps: () => ({
144
+ nav: { home: t('nav.home'), posts: t('nav.posts') }, // 요청 로케일로 번역돼 전 렌더에 주입
145
+ }),
146
+ })
147
+ ```
148
+
149
+ 읽는 쪽은 타입 브리지를 **선언 병합**으로 확장한다(코어 3종 currentUser·csrf·flash 는
150
+ 고정 · 앱 키만 추가):
151
+
152
+ ```ts
153
+ // shared/gaon-shared.d.ts (또는 아무 .d.ts)
154
+ import 'gaonjs/vue'
155
+ declare module 'gaonjs/vue' {
156
+ interface GaonSharedProps { nav: { home: string; posts: string } }
157
+ }
158
+ ```
159
+
160
+ ```vue
161
+ <!-- apps/web/layouts/Default.vue -->
162
+ <script setup lang="ts">
163
+ import { useShared, Link } from 'gaonjs/vue'
164
+ const shared = useShared() // 타입 안전 · 반응형
165
+ </script>
166
+ <template>
167
+ <nav>
168
+ <Link href="/">{{ shared.nav.home }}</Link>
169
+ <Link href="/posts">{{ shared.nav.posts }}</Link>
170
+ </nav>
171
+ </template>
172
+ ```
173
+
174
+ - **서버 주도가 The One Way** — 로케일 전환(`this.setLocale`·§3) 후 다음 요청의
175
+ render props·sharedProps 가 새 로케일로 다시 번역돼 흘러든다. 클라가 카탈로그를
176
+ 들고 다시 번역할 일이 없다(같은 정답이 둘로 갈라지지 않는다).
177
+ - **함수형 `sharedProps` 로 매 요청 번역** — `sharedProps: () => ({...})` 는 요청마다
178
+ 실행되므로 `t()` 가 그 요청의 로케일을 읽는다. 상수 객체로 굳히면 첫 로케일에
179
+ 박제된다(변이 축은 함수로 계산 · web.md §4.2).
180
+
181
+ ### 6. `<html lang>` 은 요청 로케일을 자동으로 따른다 (결정 214)
182
+
183
+ `i18n` 이 설정된 프로젝트는 최초 문서 응답(Inertia 셸)의 `<html lang>` 이 그 요청의
184
+ 협상 로케일로 자동 치환된다 — `apps/<앱>/index.html` 의 `<html lang="ko">` 는 정적
185
+ 기본값일 뿐이고, `Accept-Language: ja`(또는 `gaon_locale=ja` 쿠키·세션)면 응답 셸은
186
+ `<html lang="ja">` 로 나간다. 손으로 배선할 것이 없다(자동 · SEO·스크린리더·`:lang`
187
+ CSS 가 올바른 언어를 안다). 비-i18n 프로젝트는 템플릿 정적값을 그대로 유지한다.
188
+
189
+ - **템플릿의 `lang` 을 요청마다 바꾸려 하지 말 것** — `index.html` 은 정적 기본값만
190
+ 둔다. 실제 치환은 서버 셸 조립이 한다(SPA X-Inertia JSON 응답은 대상 아님 · 최초
191
+ 문서에만 `<html>` 이 있다).
192
+ - **로케일 전환(`this.setLocale`·§3) 후에도 추종** — 다음 요청의 협상 로케일이
193
+ 쿠키/세션으로 실려 셸 `lang` 도 함께 바뀐다.
194
+
195
+ ### 7. 로케일 커버리지 — `locale-parity` 경고 (결정 216)
196
+
197
+ `messages.d.ts` 의 키 유니온은 **기준 로케일 하나**에서 나온다(§4). 그래서 어떤 키를
198
+ `ko.json`·`en.json` 에는 넣고 `ja.json` 에만 빠뜨리면 **컴파일은 통과**하고, 런타임에
199
+ 일본어 사용자만 fallback(대개 다른 언어) 번역을 조용히 본다 — 타입도 화면도 못 잡는
200
+ 사각이다. `gaon doctor`/`gaon check` 의 `locale-parity`(§2.2 27번)가 `locales/` 의
201
+ 로케일 간 키 diff 를 계산해 빠진 파일·키를 **경고**로 짚는다(`--json` 은 `detail.missing`).
202
+ 로케일이 0·1개면 비교 대상이 없어 무소음이다. 경고이므로 빌드를 세우진 않지만, 로케일을
203
+ 추가할 때 키를 전 로케일에 나란히 채워 커버리지를 맞추는 것이 관례다.
204
+
99
205
  ## 정본 예시
100
206
 
101
207
  ```ts
@@ -123,6 +229,10 @@ export function greetLine(name: string): string {
123
229
  - **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
124
230
  를 `locales/` 에 넣으면 필드별 사유가 요청 로케일로 번역된다(`agents/web.md` §4.1 · 결정 183).
125
231
  프레임웍은 코드만 노출하고 번역은 앱 몫이다(미제공 시 내장 fallback).
232
+ - **Vue 에서 `t()` 를 부르지 말 것** — `gaonjs/vue` 에는 `t()`·`useT()` 가 없다(서버 ALS
233
+ 전용 · 결정 213). 페이지 문구는 컨트롤러 render props, 앱 chrome 은 `sharedProps` 로
234
+ 서버가 번역해 흘려보낸다(§5). 클라 번역 접근자를 자작하지 않는다(카탈로그 이중 존재·
235
+ 번들 비용).
126
236
 
127
237
  ## 관련 결정 번호
128
238
 
@@ -131,3 +241,6 @@ export function greetLine(name: string): string {
131
241
  | §7 (v0.15) | i18n 배터리 · locales/ 카탈로그 · t() |
132
242
  | 결정 158 (13차 W2) | `.gaon/messages.d.ts` 키 타입 브리지 — 없는 키 컴파일 에러 |
133
243
  | 결정 159 (13차 W1) | 요청별 로케일 자동 협상(wireGaon onRequest) · `this.setLocale` · detect 설정 |
244
+ | 결정 213 | Vue 클라 소비 = 서버 주도 render props/sharedProps 만(§5) · `t()`·`useT()` 클라 미노출(서버 ALS 전용) · sharedProps 는 결정 150 동형 |
245
+ | 결정 214 (13차 W2) | 최초 문서 셸 `<html lang>` 이 요청 협상 로케일 자동 추종(§6) · 템플릿 정적값은 기본값 · 비-i18n 무회귀 |
246
+ | 결정 216 (13차 W4) | `locale-parity` doctor 경고(§7) — 로케일 간 키 부분 누락 = fallback 조용 노출 · 기준 로케일 유니온의 사각 |
@@ -126,6 +126,23 @@ describe('PostPublished (실 NATS JetStream)', () => {
126
126
  확증하려면 `maxDeliver: 1`(첫 실패에서 즉시 드롭).
127
127
  - `configureEvents` 는 헬퍼가 대신 해 주고, 끝나면 하네스 배선을 복원한다
128
128
  (`expectJobProcessed` 와 동형 · 자기 nats 를 `close()` 해도 다음 테스트 무영향).
129
+ - **`service()` 안에서 emit 한 이벤트도 그대로 확증된다(결정 215).** 서비스 트랜잭션 안의
130
+ `emit` 은 즉시 NATS 로 가지 않고 같은 트랜잭션으로 `_gaon_outbox` 에 스테이징된다
131
+ (커밋과 발행의 원자성 · 결정 144). 운영에선 `gaon work` 릴레이가 이를 NATS 로 옮기지만
132
+ 테스트엔 릴레이가 없다 — 그래서 `expectEventProcessed` 가 **`trigger` 완료 직후 아웃박스를
133
+ 1회 자동 드레인**해 리스너가 받게 한다. 테스트 작성자는 emit 이 직접이든 `service()`
134
+ 경유든 **같은 헬퍼만** 쓴다(The One Way · 손으로 릴레이를 돌릴 필요 없음). 자동 드레인은
135
+ 표준 하네스(`connectTestDatabase` · `config.nats` + `main` 커넥션)가 배선하므로, `gaon test`
136
+ 로 도는 통합 테스트에서 그냥 성립한다.
137
+
138
+ ```ts
139
+ // service() 가 emit 하는 흐름도 같은 한 줄로 확증된다(트리거만 서비스 호출로 바꾸면 됨).
140
+ await expectEventProcessed(PostPublished, () => PublishPost.call({ postId: 1n }), { nats })
141
+ ```
142
+
143
+ - **타이밍 제어가 필요한 다단계 시나리오**만 명시 탈출구 `drainOutbox()`(`gaonjs/testing`)를
144
+ 직접 부른다 — 스테이징된 아웃박스 행을 즉시 릴레이하고 발행 건수를 돌려준다. 기본 경로엔
145
+ 필요 없다(자동 드레인이 The One Way).
129
146
 
130
147
  ### 5. DB 테스트 격리 — `gaon test` + `test/setup.ts` (결정 111)
131
148
 
@@ -256,6 +273,7 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
256
273
  | 결정 | 내용 |
257
274
  |---|---|
258
275
  | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
276
+ | 결정 215 (13차 W3) | `expectEventProcessed` 가 trigger 뒤 아웃박스 1회 자동 드레인 — `service()` 경유 emit(결정 144 스테이징)도 같은 헬퍼로 확증(§4.1) · 명시 탈출구 `drainOutbox()` |
259
277
  | 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
260
278
  | 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
261
279
  | 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
@@ -9,8 +9,10 @@
9
9
  <!--
10
10
  Vite 개발 서버가 이 index.html 을 서빙한다(dev). 운영 빌드는
11
11
  vite build 가 이 파일을 진입점 삼아 프로덕션 번들을 만든다.
12
- Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 아래와 같은 구조의
13
- <div id="app" data-page="..."> 를 내려보낸다 — 개발과 운영이 같은 셸.
12
+ Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 <div id="app"></div> 와
13
+ 함께 초기 페이지 객체를 <script type="application/json" data-page="app">…</script>
14
+ 엘리먼트로 내려보낸다(data-page 는 div 속성이 아니라 이 script 태그에 실린다) —
15
+ 개발과 운영이 같은 셸이다.
14
16
  -->
15
17
  <div id="app"></div>
16
18
  <script type="module" src="/main.ts"></script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.12.0",
31
- "@gaonjs/data": "0.16.1",
32
- "@gaonjs/config": "0.15.2",
30
+ "@gaonjs/config": "0.16.0",
31
+ "@gaonjs/async": "0.13.0",
32
+ "@gaonjs/mail": "0.2.1",
33
33
  "@gaonjs/core": "0.2.2",
34
34
  "@gaonjs/i18n": "0.2.1",
35
- "@gaonjs/web": "0.18.1",
36
- "@gaonjs/mail": "0.2.1"
35
+ "@gaonjs/data": "0.16.1",
36
+ "@gaonjs/web": "0.19.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""