@gaonjs/cli 0.66.0 → 0.67.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.
@@ -13,6 +13,12 @@ export interface GenerateResult {
13
13
  readonly name: string;
14
14
  readonly app: string | null;
15
15
  readonly write: WriteResult;
16
+ /**
17
+ * 결정 466: 이 스캐폴드만으로는 화면이 완성되지 않을 때의 다음 명령.
18
+ * `g controller` 는 `this.render('Posts/Index')` 를 내는데 그 페이지 파일은
19
+ * 아직 없다 — 짝을 만들지 않으면 부팅 후 그 화면만 죽는다(doctor render-page-exists).
20
+ */
21
+ readonly nextSteps: readonly string[];
16
22
  }
17
23
  /** argv 에서 옵션을 뽑는다(간단 파서 · runCli 관례와 일치). */
18
24
  export declare function parseGenerateArgs(argv: readonly string[]): {
@@ -16,7 +16,7 @@
16
16
  // job → domain/jobs/<camel>.ts
17
17
  import { existsSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
- import { appScaffoldFiles, channelScaffoldFiles, controllerScaffold, inflectModel, jobScaffold, jobTestScaffold, modelScaffoldFiles, pageScaffold, writeScaffold, } from '../scaffold/index.js';
19
+ import { appScaffoldFiles, channelScaffoldFiles, controllerScaffold, controllerIndexPage, inflectModel, jobScaffold, jobTestScaffold, modelScaffoldFiles, pageScaffold, writeScaffold, } from '../scaffold/index.js';
20
20
  import { appWiringFiles, readProjectName } from '../scaffold/app-wiring.js';
21
21
  import { analyzeProjectI18n } from '../i18n-config.js';
22
22
  /** argv 에서 옵션을 뽑는다(간단 파서 · runCli 관례와 일치). */
@@ -147,6 +147,7 @@ export function runGenerateCommand(type, name, opts = {}) {
147
147
  name,
148
148
  app: type === 'controller' || type === 'page' || type === 'channel' ? app : null,
149
149
  write,
150
+ nextSteps: nextStepsFor(type, name, app, cwd),
150
151
  };
151
152
  if (opts.json) {
152
153
  const ok = !failed;
@@ -164,6 +165,11 @@ export function runGenerateCommand(type, name, opts = {}) {
164
165
  for (const f of write.skipped)
165
166
  lines.push(` ✗ ${f} (이미 있음 — --overwrite 로 덮어쓰기)`);
166
167
  lines.push('');
168
+ if (!failed && result.nextSteps.length > 0) {
169
+ for (const step of result.nextSteps)
170
+ lines.push(` → ${step}`);
171
+ lines.push('');
172
+ }
167
173
  if (failed) {
168
174
  lines.push(' → --overwrite 를 붙이면 기존 파일을 덮어씁니다.');
169
175
  lines.push('');
@@ -171,3 +177,19 @@ export function runGenerateCommand(type, name, opts = {}) {
171
177
  process.stdout.write(lines.join('\n') + '\n');
172
178
  return failed ? 1 : 0;
173
179
  }
180
+ /**
181
+ * 짝이 빠져 화면이 죽는 것을 막는 다음 단계 안내(결정 466 · §7.5.3).
182
+ * `g controller` 는 렌더 타깃 페이지를 함께 만들지 않는다(JSON 전용 컨트롤러도
183
+ * 있으므로) — 대신 그 페이지가 없으면 정확히 어떤 명령을 칠지 알려 준다.
184
+ */
185
+ function nextStepsFor(type, name, app, cwd) {
186
+ if (type !== 'controller')
187
+ return [];
188
+ const page = controllerIndexPage(inflectModel(name));
189
+ if (existsSync(join(cwd, 'apps', app, 'pages', `${page}.vue`)))
190
+ return [];
191
+ return [
192
+ `짝이 되는 페이지를 만드세요: gaon g page ${page}${app === 'web' ? '' : ` --app ${app}`}` +
193
+ ` (컨트롤러가 this.render('${page}') 로 부릅니다)`,
194
+ ];
195
+ }
@@ -108,6 +108,11 @@ export const FIXER_CAPABILITIES = [
108
108
  hasFixer: false,
109
109
  note: "수동 · '.index()' 제거 자체는 안전하지만, 원래 의도가 이 컬럼을 선두로 하는 복합 인덱스나 부분 인덱스(.index({ where })) 였을 수 있어 테이블 레벨로 옮길지 지울지는 사람이 판단합니다(결정 460).",
110
110
  },
111
+ {
112
+ rule: 'render-page-exists',
113
+ hasFixer: false,
114
+ note: "수동 · render 문자열을 실재 페이지로 고치거나 `gaon g page <이름>` 으로 페이지를 만드세요 — 어느 쪽이 의도인지(오타 정정 vs 화면 신설)는 사람이 압니다(결정 466).",
115
+ },
111
116
  {
112
117
  rule: 'column-casing',
113
118
  hasFixer: false,
@@ -0,0 +1,15 @@
1
+ import type { RuleReport } from './types.js';
2
+ export interface RenderTarget {
3
+ readonly action: string;
4
+ readonly name: string;
5
+ readonly line: number;
6
+ }
7
+ /** 컨트롤러 소스 하나에서 문자열 리터럴 render 타깃을 수집한다(단위 테스트 진입점). */
8
+ export declare function collectRenderTargets(file: string, source: string): RenderTarget[];
9
+ /**
10
+ * 없는 타깃에 대한 수리 후보를 고른다(§7.5.3 — 에러가 곧 수리 안내서).
11
+ * ① 대소문자만 다른 것 ② 마지막 세그먼트가 같고 폴더가 단/복수 변형인 것.
12
+ */
13
+ export declare function suggestPages(target: string, pages: readonly string[]): string[];
14
+ /** apps/ 를 훑어 실재하지 않는 render 타깃을 낸다(error 등급 — 확정 런타임 파손). */
15
+ export declare function checkRenderPageExists(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,197 @@
1
+ // @gaonjs/cli · doctor · render 타깃 페이지 실재 검사 (결정 466)
2
+ //
3
+ // `this.render('Posts/Index')` 의 첫 인자는 **타입이 아니라 런타임 문자열**이다 —
4
+ // Inertia 클라이언트가 `./pages/<이름>.vue` 를 glob 지도에서 **정확 일치**로 찾는다
5
+ // (packages/vue/src/runtime.ts). 그래서 이름이 한 글자만 어긋나도 tsc·vue-tsc·
6
+ // `gaon check` 는 전부 통과하고 그 화면만 부팅 후에 죽는다(무신호 파손 클래스).
7
+ //
8
+ // 이 검사가 그 런타임 실패를 `gaon check` 시점으로 당긴다: 컨트롤러의 문자열 리터럴
9
+ // render 타깃이 같은 앱 `pages/` 아래 실제 .vue 파일을 가리키는지 본다. 리터럴이
10
+ // 아닌 인자(변수·템플릿 리터럴)는 정적으로 알 수 없어 건너뛴다(오탐 방지).
11
+ //
12
+ // 대소문자: 파일 목록을 실제로 읽어 **정확 문자열**로 비교한다 — existsSync 는
13
+ // 대소문자 무시 파일시스템(macOS)에서 'posts/index' 를 통과시켜, 배포(리눅스)에서만
14
+ // 죽는 차이를 숨긴다.
15
+ import { readdir, readFile } from 'node:fs/promises';
16
+ import { join, relative } from 'node:path';
17
+ import ts from 'typescript';
18
+ import { pluralize, singularize, toCamel, toPascal } from '../scaffold/inflect.js';
19
+ /** 컨트롤러 소스 하나에서 문자열 리터럴 render 타깃을 수집한다(단위 테스트 진입점). */
20
+ export function collectRenderTargets(file, source) {
21
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
22
+ const found = [];
23
+ const visit = (node) => {
24
+ if (ts.isCallExpression(node) && isControllerCall(node)) {
25
+ const arg = node.arguments[0];
26
+ if (arg && ts.isObjectLiteralExpression(arg)) {
27
+ for (const prop of arg.properties) {
28
+ const action = actionName(prop);
29
+ const body = actionBody(prop);
30
+ if (!action || !body)
31
+ continue;
32
+ collectInBody(sf, body, action, found);
33
+ }
34
+ }
35
+ }
36
+ ts.forEachChild(node, visit);
37
+ };
38
+ visit(sf);
39
+ return found;
40
+ }
41
+ function isControllerCall(node) {
42
+ const e = node.expression;
43
+ if (ts.isIdentifier(e) && e.text === 'controller')
44
+ return true;
45
+ if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
46
+ return true;
47
+ return false;
48
+ }
49
+ function actionName(prop) {
50
+ if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
51
+ return prop.name.text;
52
+ if (ts.isPropertyAssignment(prop) &&
53
+ ts.isIdentifier(prop.name) &&
54
+ (ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
55
+ return prop.name.text;
56
+ }
57
+ return undefined;
58
+ }
59
+ function actionBody(prop) {
60
+ if (ts.isMethodDeclaration(prop))
61
+ return prop.body;
62
+ if (ts.isPropertyAssignment(prop)) {
63
+ if (ts.isArrowFunction(prop.initializer))
64
+ return prop.initializer.body;
65
+ if (ts.isFunctionExpression(prop.initializer))
66
+ return prop.initializer.body;
67
+ }
68
+ return undefined;
69
+ }
70
+ /** 액션 본문 안의 `this.render('리터럴', …)` 을 모두 모은다(중첩 콜백 포함). */
71
+ function collectInBody(sf, body, action, out) {
72
+ const visit = (node) => {
73
+ if (ts.isCallExpression(node)) {
74
+ const c = node.expression;
75
+ if (ts.isPropertyAccessExpression(c) &&
76
+ c.expression.kind === ts.SyntaxKind.ThisKeyword &&
77
+ c.name.text === 'render') {
78
+ const arg = node.arguments[0];
79
+ // 리터럴만 판정한다 — 변수·템플릿 리터럴은 정적으로 값을 모른다.
80
+ if (arg && ts.isStringLiteral(arg)) {
81
+ const { line } = sf.getLineAndCharacterOfPosition(arg.getStart(sf));
82
+ out.push({ action, name: arg.text, line: line + 1 });
83
+ }
84
+ }
85
+ }
86
+ ts.forEachChild(node, visit);
87
+ };
88
+ visit(body);
89
+ }
90
+ /**
91
+ * 없는 타깃에 대한 수리 후보를 고른다(§7.5.3 — 에러가 곧 수리 안내서).
92
+ * ① 대소문자만 다른 것 ② 마지막 세그먼트가 같고 폴더가 단/복수 변형인 것.
93
+ */
94
+ export function suggestPages(target, pages) {
95
+ const lower = target.toLowerCase();
96
+ const byCase = pages.filter((p) => p.toLowerCase() === lower);
97
+ if (byCase.length > 0)
98
+ return byCase;
99
+ const segs = target.split('/');
100
+ const last = segs[segs.length - 1];
101
+ const head = segs.slice(0, -1).join('/');
102
+ const variants = new Set();
103
+ if (head) {
104
+ // 'Post' ↔ 'Posts' — 정본은 복수 리소스 폴더다(결정 466).
105
+ const camel = toCamel(head);
106
+ variants.add([toPascal(pluralize(camel)), last].join('/'));
107
+ variants.add([toPascal(singularize(camel)), last].join('/'));
108
+ }
109
+ else {
110
+ variants.add(`${toPascal(pluralize(toCamel(last)))}/Index`);
111
+ }
112
+ variants.delete(target);
113
+ return pages.filter((p) => variants.has(p));
114
+ }
115
+ /** apps/ 를 훑어 실재하지 않는 render 타깃을 낸다(error 등급 — 확정 런타임 파손). */
116
+ export async function checkRenderPageExists(cwd) {
117
+ const appsDir = join(cwd, 'apps');
118
+ const issues = [];
119
+ for (const app of await safeListDirs(appsDir)) {
120
+ const pagesDir = join(appsDir, app, 'pages');
121
+ const pages = await collectPageNames(pagesDir);
122
+ const ctrlDir = join(appsDir, app, 'controllers');
123
+ for (const file of await safeListFiles(ctrlDir)) {
124
+ if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
125
+ continue;
126
+ const full = join(ctrlDir, file);
127
+ const rel = relative(cwd, full).split('\\').join('/');
128
+ const source = await readFile(full, 'utf8');
129
+ for (const t of collectRenderTargets(full, source)) {
130
+ if (pages.includes(t.name))
131
+ continue;
132
+ const suggestions = suggestPages(t.name, pages);
133
+ const hint = suggestions.length > 0
134
+ ? `→ 실재하는 가까운 페이지: ${suggestions.map((s) => `'${s}'`).join(', ')} — ` +
135
+ `render 문자열을 그 이름으로 바꾸세요.\n`
136
+ : '';
137
+ issues.push({
138
+ rule: 'render-page-exists',
139
+ level: 'error',
140
+ file: rel,
141
+ line: t.line,
142
+ message: `없는 페이지를 렌더합니다: ${rel}:${t.line} · 액션 '${t.action}' 의 ` +
143
+ `this.render('${t.name}', …) 가 apps/${app}/pages/${t.name}.vue 를 가리키는데 그 파일이 없습니다.\n` +
144
+ `→ Inertia 는 페이지를 **정확 일치**로 찾습니다 — 컴파일은 통과하고(render 인자는 런타임 문자열) ` +
145
+ `이 화면만 부팅 후에 "페이지를 찾을 수 없습니다" 로 죽습니다.\n` +
146
+ hint +
147
+ `→ 또는 \`gaon g page ${t.name}${app === 'web' ? '' : ` --app ${app}`}\` 로 페이지를 만드세요. ` +
148
+ `(리소스 폴더는 복수·PascalCase 가 정본입니다 — 'Posts/Index' ↔ 라우트 키 'posts#index' · 결정 466.)`,
149
+ detail: { app, action: t.action, target: t.name, suggestions },
150
+ });
151
+ }
152
+ }
153
+ }
154
+ return { rule: 'render-page-exists', issues };
155
+ }
156
+ /** pages/ 아래 .vue 를 재귀 수집해 render 이름(확장자·경로 접두 없음)으로 만든다. */
157
+ async function collectPageNames(pagesDir) {
158
+ const out = [];
159
+ const walk = async (dir, prefix) => {
160
+ let entries;
161
+ try {
162
+ entries = (await readdir(dir, { withFileTypes: true }));
163
+ }
164
+ catch {
165
+ return;
166
+ }
167
+ for (const e of entries) {
168
+ if (e.isDirectory()) {
169
+ await walk(join(dir, e.name), prefix ? `${prefix}/${e.name}` : e.name);
170
+ }
171
+ else if (e.isFile() && e.name.endsWith('.vue')) {
172
+ const stem = e.name.slice(0, -'.vue'.length);
173
+ out.push(prefix ? `${prefix}/${stem}` : stem);
174
+ }
175
+ }
176
+ };
177
+ await walk(pagesDir, '');
178
+ return out;
179
+ }
180
+ async function safeListDirs(dir) {
181
+ try {
182
+ const entries = await readdir(dir, { withFileTypes: true });
183
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
184
+ }
185
+ catch {
186
+ return [];
187
+ }
188
+ }
189
+ async function safeListFiles(dir) {
190
+ try {
191
+ const entries = await readdir(dir, { withFileTypes: true });
192
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch' | 'i18n-layout' | 'i18n-app-scope' | 'i18n-server-scope' | 'redundant-index';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch' | 'i18n-layout' | 'i18n-app-scope' | 'i18n-server-scope' | 'redundant-index' | 'render-page-exists';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -8,6 +8,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
8
8
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
9
9
  export { checkSchemaRelations } from './doctor/schema-relations.js';
10
10
  export { checkRedundantIndex } from './doctor/redundant-index.js';
11
+ export { checkRenderPageExists, collectRenderTargets } from './doctor/render-page-exists.js';
11
12
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
12
13
  export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
13
14
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
@@ -37,7 +38,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
37
38
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
38
39
  */
39
40
  /**
40
- * doctor 정적 검사 37종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
41
+ * doctor 정적 검사 38종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
41
42
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
42
43
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
43
44
  */
package/dist/doctor.js CHANGED
@@ -40,6 +40,7 @@
40
40
  * shared/ 가 쓰는 키는 전 앱에 있어야 한다)
41
41
  * 35) i18n-server-scope (결정 459 · 소유자 경계를 넘는 서버 t() 키 = 워커·크론에서 조용히 빔)
42
42
  * 36) redundant-index (결정 460 · unique·PK 컬럼의 `.index()` = DDL 이 조용히 건너뜀 경고)
43
+ * 37) render-page-exists (결정 466 · this.render 타깃 .vue 부재 = 부팅 후 그 화면만 죽음 error)
43
44
  *
44
45
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
45
46
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -59,6 +60,7 @@ import { checkDependencyDirection } from './doctor/dependency-direction.js';
59
60
  import { checkConnections } from './doctor/connections.js';
60
61
  import { checkSchemaRelations } from './doctor/schema-relations.js';
61
62
  import { checkRedundantIndex } from './doctor/redundant-index.js';
63
+ import { checkRenderPageExists } from './doctor/render-page-exists.js';
62
64
  import { checkMigrationDiff } from './doctor/migration-diff.js';
63
65
  import { checkSharedPurity } from './doctor/shared-purity.js';
64
66
  import { checkNoAutoImport } from './doctor/no-auto-import.js';
@@ -100,6 +102,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
100
102
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
101
103
  export { checkSchemaRelations } from './doctor/schema-relations.js';
102
104
  export { checkRedundantIndex } from './doctor/redundant-index.js';
105
+ export { checkRenderPageExists, collectRenderTargets } from './doctor/render-page-exists.js';
103
106
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
104
107
  export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
105
108
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
@@ -129,7 +132,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
129
132
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
130
133
  */
131
134
  /**
132
- * doctor 정적 검사 37종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
135
+ * doctor 정적 검사 38종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
133
136
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
134
137
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
135
138
  */
@@ -171,6 +174,7 @@ export const ALL_RULES = [
171
174
  'i18n-app-scope',
172
175
  'i18n-server-scope',
173
176
  'redundant-index',
177
+ 'render-page-exists',
174
178
  ];
175
179
  /**
176
180
  * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
@@ -216,6 +220,7 @@ export const RULE_SUMMARIES = {
216
220
  'dotenv-node-env': '.env NODE_ENV',
217
221
  'page-fetch': '세션 앱 raw fetch',
218
222
  'redundant-index': 'unique·PK 컬럼의 무시되는 .index()',
223
+ 'render-page-exists': '없는 페이지 render',
219
224
  };
220
225
  const CHECKERS = {
221
226
  'response-mixing': checkResponseMixing,
@@ -250,6 +255,7 @@ const CHECKERS = {
250
255
  'i18n-app-scope': checkI18nAppScope,
251
256
  'i18n-server-scope': checkI18nServerScope,
252
257
  'redundant-index': checkRedundantIndex,
258
+ 'render-page-exists': checkRenderPageExists,
253
259
  'render-return': checkRenderReturn,
254
260
  'channel-collision': checkChannelCollision,
255
261
  'channel-instance-authorize': checkChannelInstanceAuthorize,
@@ -4,6 +4,11 @@ export interface ScaffoldFile {
4
4
  readonly path: string;
5
5
  readonly contents: string;
6
6
  }
7
+ /**
8
+ * 컨트롤러 index 액션이 렌더할 페이지 이름(결정 466) — `gaon g page` 인자와 같은 문자열.
9
+ * 컨트롤러 스캐폴드와 CLI 안내가 이 하나를 함께 읽어 짝이 어긋날 수 없게 한다.
10
+ */
11
+ export declare function controllerIndexPage(names: ModelNames): string;
7
12
  /**
8
13
  * 컨트롤러 스캐폴드 파일을 만든다.
9
14
  * @param names 이름 변형(카멜/파스칼/복수) — inflect() 결과.
@@ -12,6 +12,18 @@
12
12
  // 모델 참조: apps/<app>/controllers/*.ts → domain/models/*.ts. 프로젝트 관습
13
13
  // (CLAUDE.md §2): 모델·잡은 domain/ 아래 두어 앱 간 재사용을 허용한다
14
14
  // (apps→apps 금지 · rule 5).
15
+ import { toPascal } from './inflect.js';
16
+ /**
17
+ * 컨트롤러 index 액션이 렌더할 페이지 이름(결정 466) — `gaon g page` 인자와 같은 문자열.
18
+ * 컨트롤러 스캐폴드와 CLI 안내가 이 하나를 함께 읽어 짝이 어긋날 수 없게 한다.
19
+ */
20
+ export function controllerIndexPage(names) {
21
+ // 페이지 경로는 **복수 리소스 폴더**다(`Posts/Index`) — 라우트 키 `posts#index`·
22
+ // 컨트롤러 파일 `posts.ts`·`gaon g page Posts/Index` 와 한 짝이다. 단수(`Post/Index`)로
23
+ // 내면 Inertia 해상이 정확 일치라 컴파일은 통과하고 이 화면만 런타임에 죽는다
24
+ // (render 인자는 타입이 아니라 문자열).
25
+ return `${toPascal(names.plural)}/Index`;
26
+ }
15
27
  /**
16
28
  * 컨트롤러 스캐폴드 파일을 만든다.
17
29
  * @param names 이름 변형(카멜/파스칼/복수) — inflect() 결과.
@@ -21,6 +33,7 @@ export function controllerScaffold(names, app) {
21
33
  // 라우트 키·api() 키는 **앱 접두**를 포함한다(결정 55 · 'app:controller#action') —
22
34
  // 접두 없는 'posts#count' 는 멀티앱에서 해상되지 않아 주석 그대로 복사하면 컴파일이 깨진다.
23
35
  const { pascal, plural } = names;
36
+ const indexPage = controllerIndexPage(names); // 결정 466: 페이지 짝의 단일 출처
24
37
  const lines = [
25
38
  `// ${plural} 컨트롤러 — gaon g controller (M9-B).`,
26
39
  `// 페이지 액션(this.render)과 JSON 액션(반환값=응답 · errata E-3) 예시를 담는다.`,
@@ -35,7 +48,7 @@ export function controllerScaffold(names, app) {
35
48
  ` // 전송도 안 되고 **클로저도 안 돈다** — 목록만 다시 조회된다(결정 462·463).`,
36
49
  ` async index() {`,
37
50
  ` const q = this.listQuery()`,
38
- ` return this.render('${pascal}/Index', {`,
51
+ ` return this.render('${indexPage}', {`,
39
52
  ` ${plural}: this.lazy(() => ${pascal}.latest().paginate(q.page, q.size)),`,
40
53
  ` })`,
41
54
  ` },`,
@@ -1,5 +1,5 @@
1
1
  export type { ScaffoldFile } from './controller.js';
2
- export { controllerScaffold } from './controller.js';
2
+ export { controllerScaffold, controllerIndexPage } from './controller.js';
3
3
  export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
4
4
  export { pageScaffold } from './page.js';
5
5
  export { jobScaffold, jobTestScaffold } from './job.js';
@@ -5,7 +5,7 @@
5
5
  // 반환한다. 실제 쓰기는 writeScaffold 가 담당(멱등·overwrite 옵션 처리).
6
6
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, join, resolve } from 'node:path';
8
- export { controllerScaffold } from './controller.js';
8
+ export { controllerScaffold, controllerIndexPage } from './controller.js';
9
9
  export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
10
10
  export { pageScaffold } from './page.js';
11
11
  export { jobScaffold, jobTestScaffold } from './job.js';
@@ -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` 검사 37
116
+ ### 2.2 `gaon doctor` 검사 38
117
117
 
118
118
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
119
119
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -152,6 +152,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
152
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
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
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)
155
+ 38. `render-page-exists` — 컨트롤러 `this.render('X/Y')` 가 **없는 페이지**를 가리킴 = **에러**. render 의 첫 인자는 타입이 아니라 **런타임 문자열**이고, Inertia 는 `apps/<앱>/pages/X/Y.vue` 를 **정확 일치**로 찾는다 — 한 글자만 어긋나도 tsc·vue-tsc·`gaon check` 는 전부 통과하고 그 화면만 부팅 후에 죽는다(단수/복수 혼동이 대표 사례 · 결정 466). 리소스 폴더는 **복수·PascalCase** 가 정본이다: 컨트롤러 `posts.ts` ↔ 라우트 키 `posts#index` ↔ 페이지 `Posts/Index.vue`. 수리: render 문자열을 실재 페이지로 고치거나 `gaon g page Posts/Index` 로 만든다(비-web 앱은 `--app <앱>`). 문자열 리터럴만 판정한다(변수·템플릿 리터럴 인자는 정적으로 값을 몰라 제외) · 파일 목록을 실제로 읽어 대소문자까지 정확 비교한다(대소문자 무시 파일시스템에서 통과했다가 리눅스 배포에서만 죽는 차이 방지) · `--fix` 없음(오타 정정인지 화면 신설인지는 의도 판단) (결정 466)
155
156
 
156
157
  ## 3. 로직 배치 One Way 판단표
157
158
 
@@ -212,7 +213,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
212
213
  ```bash
213
214
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
214
215
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
215
- gaon doctor # 정적 검사 37종 (§2.2)
216
+ gaon doctor # 정적 검사 38종 (§2.2)
216
217
  ```
217
218
 
218
219
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -723,6 +723,13 @@ async function runSearch(q: string) {
723
723
  - **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
724
724
  Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
725
725
  (결정 32·46). rename 후 컨트롤러 `this.render('...')` 키도 맞춘다.
726
+ - **리소스 폴더는 복수형이다 (결정 466)** — `posts.ts`(컨트롤러) ↔ `posts#index`
727
+ (라우트 키) ↔ `Posts/Index.vue`(페이지)가 한 짝이다. 단수 `Post/Index.vue` 는
728
+ `pageProps<'web:post#index'>()` 로 이어져 어느 라우트 키와도 맞지 않는다.
729
+ `gaon g controller Post` 와 `gaon g page Posts/Index` 가 같은 짝을 낸다.
730
+ - **render 타깃과 페이지 파일이 어긋나면 부팅 후에만 죽는다** — 페이지 해상은
731
+ **정확 일치**라 컴파일 게이트가 통째로 통과한다. doctor **render-page-exists** 가
732
+ 없는 render 타깃을 **에러**로 당겨 잡는다(결정 466).
726
733
  - **템플릿 `:key="String(p.id)"` 방어 금지** — 정규화는 컨트롤러 한 곳
727
734
  (결정 37).
728
735
  - **`v-html` 은 XSS 탈출구** — 사용자 입력을 넣지 않는다
@@ -792,6 +792,13 @@ export default controller({
792
792
  빈 204 로 나간다. 렌더·리다이렉트·JSON 은 항상 `return` 과 함께 쓴다.
793
793
  `gaon doctor` 의 **render-return** 검사가 이 패턴(호출만 하고 return 누락)을
794
794
  경고로 잡는다(결정 340 · 의도된 204 는 응답 호출 없이 그냥 return).
795
+ - **`this.render('...')` 의 페이지 이름은 타입이 아니라 런타임 문자열이다(결정 466)** —
796
+ Inertia 는 `apps/<앱>/pages/<이름>.vue` 를 **정확 일치**로 찾는다. 한 글자만
797
+ 어긋나도 tsc·vue-tsc·`gaon check` 는 전부 통과하고 그 화면만 부팅 후에 죽는다.
798
+ **리소스 폴더는 복수·PascalCase 가 정본**이다 — 컨트롤러 `posts.ts` ↔ 라우트 키
799
+ `posts#index` ↔ 페이지 `Posts/Index.vue` 가 한 짝이고, 단수 `Post/Index` 는 어긋난다.
800
+ `gaon doctor` 의 **render-page-exists** 검사가 없는 타깃을 **에러**로 잡는다(문자열
801
+ 리터럴만 판정 · 수리 안내에 가까운 후보와 `gaon g page` 명령을 함께 낸다).
795
802
  - **라우트 타깃 형식 불량은 부팅 에러다(결정 293)** — `r.get('/x', 'posts')`
796
803
  처럼 `#액션` 을 빠뜨리면 이전엔 조용히 라우트가 사라져 무신호 404 였다.
797
804
  이제 `routes()` 가 부팅에서 throw 한다(`'<컨트롤러>#<액션>'` 형식 필수).
@@ -862,6 +869,7 @@ export default controller({
862
869
  | 결정 338 | `gaon g auth --jwt --app <api>` — API 앱 토큰 스캐폴드(발급/재발급/내 정보 · 페이지·가입 없음 · `<APP>_JWT_SECRET` 시드 · §6) |
863
870
  | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }`(생략 = 전역 상속 · rate limit 버킷은 앱 단위 · `agents/security.md` §1) |
864
871
  | 결정 340 | doctor `render-return` — 응답 호출만 하고 return 누락 = 무신호 204 경고(함정 §알려진 함정) |
872
+ | 결정 466 | `gaon g controller` render 타깃 = 복수 리소스 폴더(`Posts/Index`) · doctor `render-page-exists` 로 없는 페이지 render 를 에러(함정 §알려진 함정) |
865
873
  | 결정 389 | 앱 스코프 보안 override 는 전역과 **필드 병합** — 부분 override 가 나머지 필드를 코어 기본으로 리셋하지 않음 · CORS 는 origin 미명시 시 fail-closed(`agents/security.md` §1) |
866
874
  | 결정 390 | Inertia 렌더의 `Vary: X-Inertia` 는 기존 Vary(CORS `Origin` 등)에 **병합**(치환 아님) |
867
875
  | 결정 391 | JWT 보강 — `refresh` 가 `loadUser(sub)` 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§6) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,10 +35,10 @@
35
35
  "@gaonjs/async": "0.22.0",
36
36
  "@gaonjs/config": "0.27.0",
37
37
  "@gaonjs/core": "0.3.0",
38
- "@gaonjs/i18n": "0.5.0",
39
- "@gaonjs/data": "0.26.4",
40
38
  "@gaonjs/mail": "0.5.4",
41
- "@gaonjs/web": "0.34.0"
39
+ "@gaonjs/web": "0.34.0",
40
+ "@gaonjs/i18n": "0.5.0",
41
+ "@gaonjs/data": "0.26.4"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.rmSync('dist/templates/project',{recursive:true,force:true});fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""