@gaonjs/cli 0.65.4 → 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.
- package/dist/commands/g.d.ts +6 -0
- package/dist/commands/g.js +23 -1
- package/dist/doctor/fixers/index.js +5 -0
- package/dist/doctor/render-page-exists.d.ts +15 -0
- package/dist/doctor/render-page-exists.js +197 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +7 -1
- package/dist/scaffold/controller.d.ts +5 -0
- package/dist/scaffold/controller.js +20 -3
- package/dist/scaffold/index.d.ts +1 -1
- package/dist/scaffold/index.js +1 -1
- package/dist/scaffold/page.js +58 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/agents/frontend.md.tpl +82 -6
- package/dist/templates/project/agents/web.md.tpl +67 -4
- package/package.json +5 -5
package/dist/commands/g.d.ts
CHANGED
|
@@ -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[]): {
|
package/dist/commands/g.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -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 정적 검사
|
|
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 정적 검사
|
|
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) 예시를 담는다.`,
|
|
@@ -30,10 +43,14 @@ export function controllerScaffold(names, app) {
|
|
|
30
43
|
``,
|
|
31
44
|
`export default controller({`,
|
|
32
45
|
` // GET /${plural} — 목록 페이지 (Inertia render).`,
|
|
33
|
-
` //
|
|
46
|
+
` // 목록 계약(결정 464): 표준 쿼리 이름을 this.listQuery() 로 읽고, 봉투 하나를`,
|
|
47
|
+
` // this.lazy() 로 싣는다. 쪽을 넘길 때(부분 리로드) 지명되지 않은 prop 은`,
|
|
48
|
+
` // 전송도 안 되고 **클로저도 안 돈다** — 목록만 다시 조회된다(결정 462·463).`,
|
|
34
49
|
` async index() {`,
|
|
35
|
-
` const
|
|
36
|
-
` return this.render('${
|
|
50
|
+
` const q = this.listQuery()`,
|
|
51
|
+
` return this.render('${indexPage}', {`,
|
|
52
|
+
` ${plural}: this.lazy(() => ${pascal}.latest().paginate(q.page, q.size)),`,
|
|
53
|
+
` })`,
|
|
37
54
|
` },`,
|
|
38
55
|
``,
|
|
39
56
|
` // GET /${plural}/count.json — JSON 액션 (errata E-3).`,
|
package/dist/scaffold/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/scaffold/index.js
CHANGED
|
@@ -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';
|
package/dist/scaffold/page.js
CHANGED
|
@@ -29,6 +29,12 @@ export function pageScaffold(pagePath, app) {
|
|
|
29
29
|
// 않는 키가 나와 pageProps 가 해상되지 않는다(gaon g page BlogPosts/EditForm 실측).
|
|
30
30
|
const routeKey = `${app}:${toCamel(parent)}#${toCamel(last)}`;
|
|
31
31
|
const filePath = `apps/${app}/pages/${trimmed}.vue`;
|
|
32
|
+
// 결정 465: `<리소스>/Index` 는 **목록 화면**이다 — 짝이 되는 컨트롤러 스캐폴드가
|
|
33
|
+
// 봉투 prop(`this.lazy(() => …paginate())`)을 내므로 여기서 usePagedList 로 받는다.
|
|
34
|
+
// 이 파일이 AI 가 목록을 처음 모방하는 코드라, 정본 패턴이 여기 있어야 한다.
|
|
35
|
+
if (segments.length >= 2 && last === 'Index') {
|
|
36
|
+
return { path: filePath, contents: listPage(routeKey, toCamel(parent)) };
|
|
37
|
+
}
|
|
32
38
|
const lines = [
|
|
33
39
|
`<script setup lang="ts">`,
|
|
34
40
|
`import { pageProps } from 'gaonjs/vue'`,
|
|
@@ -48,3 +54,55 @@ export function pageScaffold(pagePath, app) {
|
|
|
48
54
|
];
|
|
49
55
|
return { path: filePath, contents: lines.join('\n') };
|
|
50
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* 목록 화면(`<리소스>/Index`)의 정본 골격 — 서버 페이지네이션 + 부분 리로드(결정 465).
|
|
59
|
+
* `only` 를 손으로 쓰지 않는 것이 핵심이다: 봉투 prop 이름 하나가 곧 요청 이름이라
|
|
60
|
+
* "적어 놨는데 안 도는" 사고도, "4개 중 하나를 빠뜨려 건수만 옛 값" 도 날 수 없다.
|
|
61
|
+
*/
|
|
62
|
+
function listPage(routeKey, envelopeKey) {
|
|
63
|
+
return [
|
|
64
|
+
`<script setup lang="ts">`,
|
|
65
|
+
`import { pageProps, usePagedList } from 'gaonjs/vue'`,
|
|
66
|
+
``,
|
|
67
|
+
`// 컨트롤러 ${routeKey} 의 render props 타입이 그대로 흐른다(§6.2).`,
|
|
68
|
+
`// 라우트 키가 다르면 이 리터럴을 바꾼다 — .gaon/routes.d.ts 가 유효한 키를 알려준다.`,
|
|
69
|
+
`const props = pageProps<'${routeKey}'>()`,
|
|
70
|
+
``,
|
|
71
|
+
`// 두 번째 인자는 목록 봉투 prop 의 이름이고, 그대로 부분 리로드 대상이 된다 —`,
|
|
72
|
+
`// 쪽을 넘기면 서버는 이 prop 만 다시 조회한다(결정 462·464·465).`,
|
|
73
|
+
`const list = usePagedList(props, '${envelopeKey}')`,
|
|
74
|
+
``,
|
|
75
|
+
`function goTo(item: number | '…'): void {`,
|
|
76
|
+
` if (typeof item === 'number') list.page = item`,
|
|
77
|
+
`}`,
|
|
78
|
+
`</script>`,
|
|
79
|
+
``,
|
|
80
|
+
`<template>`,
|
|
81
|
+
` <div>`,
|
|
82
|
+
` <h1>${envelopeKey}</h1>`,
|
|
83
|
+
``,
|
|
84
|
+
` <input v-model="list.q" placeholder="검색" />`,
|
|
85
|
+
` <p>전체 {{ list.total }}건 · {{ list.page }}/{{ list.pageCount }}쪽</p>`,
|
|
86
|
+
``,
|
|
87
|
+
` <!-- 행의 실제 컬럼으로 바꾸세요. :key 도 고유 식별자(보통 row.id)로 바꿉니다. -->`,
|
|
88
|
+
` <ul>`,
|
|
89
|
+
` <li v-for="(row, i) in list.rows" :key="i"><pre>{{ row }}</pre></li>`,
|
|
90
|
+
` </ul>`,
|
|
91
|
+
``,
|
|
92
|
+
` <!-- 이동은 컴포저블이 소유하고 UI 는 그리기만 한다(결정 25·105·106).`,
|
|
93
|
+
` 킷 Pagination 을 쓸 땐 <Pagination v-model:page="list.page" :page-count="list.pageCount" /> -->`,
|
|
94
|
+
` <nav>`,
|
|
95
|
+
` <button`,
|
|
96
|
+
` v-for="(item, i) in list.pageItems"`,
|
|
97
|
+
` :key="i"`,
|
|
98
|
+
` :disabled="item === '…' || item === list.page || list.loading"`,
|
|
99
|
+
` @click="goTo(item)"`,
|
|
100
|
+
` >`,
|
|
101
|
+
` {{ item }}`,
|
|
102
|
+
` </button>`,
|
|
103
|
+
` </nav>`,
|
|
104
|
+
` </div>`,
|
|
105
|
+
`</template>`,
|
|
106
|
+
``,
|
|
107
|
+
].join('\n');
|
|
108
|
+
}
|
|
@@ -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` 검사 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 # 정적 검사
|
|
216
|
+
gaon doctor # 정적 검사 38종 (§2.2)
|
|
216
217
|
```
|
|
217
218
|
|
|
218
219
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -98,6 +98,66 @@ const props = pageProps<'web:posts#index'>()
|
|
|
98
98
|
- **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
|
|
99
99
|
금지 (라우트를 모른다는 순수 규칙).
|
|
100
100
|
|
|
101
|
+
### 1.1 목록 화면 — `usePagedList(props, '<봉투키>')` (결정 462~465)
|
|
102
|
+
|
|
103
|
+
서버 페이지네이션 목록의 배선(쪽 이동 · URL 동기화 · 검색 디바운스 · 정렬 · 부분
|
|
104
|
+
리로드)은 컴포저블 하나가 소유한다. 화면이 쓰는 것은 **두 줄**이다.
|
|
105
|
+
|
|
106
|
+
컨트롤러는 목록 계약(`agents/web.md` §4.5)대로 봉투 prop 하나를 `this.lazy` 로 낸다:
|
|
107
|
+
`products: this.lazy(() => Product.latest().paginate(q.page, q.size))`.
|
|
108
|
+
|
|
109
|
+
```vue
|
|
110
|
+
<!-- apps/web/pages/Products/Index.vue -->
|
|
111
|
+
<script setup lang="ts">
|
|
112
|
+
import { pageProps, usePagedList } from 'gaonjs/vue'
|
|
113
|
+
|
|
114
|
+
const props = pageProps<'web:products#index'>()
|
|
115
|
+
|
|
116
|
+
// 두 번째 인자 = 목록 봉투 prop 의 이름. 이 이름이 그대로 부분 리로드 대상이 되므로
|
|
117
|
+
// `only` 를 손으로 적을 일이 없다(오타는 컴파일 에러 · 자동완성은 봉투 키만 보여 준다).
|
|
118
|
+
const list = usePagedList(props, 'products')
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<template>
|
|
122
|
+
<div>
|
|
123
|
+
<input v-model="list.q" placeholder="검색" />
|
|
124
|
+
<p>전체 {{ list.total }}건 · {{ list.page }}/{{ list.pageCount }}쪽</p>
|
|
125
|
+
|
|
126
|
+
<ul>
|
|
127
|
+
<li v-for="row in list.rows" :key="row.id">{{ row.name }}</li>
|
|
128
|
+
</ul>
|
|
129
|
+
|
|
130
|
+
<button :disabled="list.loading || list.page >= list.pageCount" @click="list.page = list.page + 1">
|
|
131
|
+
다음 쪽
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| 읽는 값 | 뜻 |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `list.rows` · `list.total` · `list.pageCount` · `list.perPage` | 서버 봉투 그대로(읽기 전용) |
|
|
140
|
+
| `list.loading` | 부분 리로드 진행 중(스켈레톤·버튼 비활성화) |
|
|
141
|
+
| `list.pageItems` | `[1,'…',4,5,6,'…',20]` 쪽 번호 띠 — 직접 만들면 늘 틀리는 자리 |
|
|
142
|
+
|
|
143
|
+
| 쓰는 값 | 일어나는 일 |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `list.page = n` | 그 쪽으로 이동(서버가 클램프한 값이 되돌아온다) |
|
|
146
|
+
| `list.size = n` · `list.filters.<이름> = v` | **1쪽으로 되돌리고** 재조회 |
|
|
147
|
+
| `list.q = '…'` | 320ms 디바운스 후 1쪽으로 재조회 |
|
|
148
|
+
| `list.sort('price')` | 같은 키면 방향 뒤집기, 다른 키면 그 키 오름차순 → 서버 정렬 |
|
|
149
|
+
| `list.reload()` | 지금 조건 그대로 재조회(저장·삭제 뒤) |
|
|
150
|
+
|
|
151
|
+
- **UI 킷은 그대로다.** `Pagination` 은 `v-model:page` 이고 라우트를 모른다(결정 25·105·106) —
|
|
152
|
+
`<Pagination v-model:page="list.page" :page-count="list.pageCount" />` 로 물리면 끝이다.
|
|
153
|
+
**이동은 컴포저블이 소유하고 킷은 그리기만 한다.**
|
|
154
|
+
- **주소가 상태다.** 조건은 쿼리(`?page=2&q=김&sort=price&dir=desc`)에 실려 링크가 공유
|
|
155
|
+
가능하고, 기본값인 항목은 주소에서 빠진다. 화면이 쓰는 다른 파라미터(`?tab=…`)는 보존된다.
|
|
156
|
+
- **히스토리 기본값은 `replace`** — 쪽 넘김마다 히스토리가 쌓이면 뒤로가기가 "이전 화면" 이
|
|
157
|
+
아니라 "이전 쪽" 이 된다. 공개 목록에서 쪽마다 주소를 남기려면
|
|
158
|
+
`usePagedList(props, 'products', { history: 'push' })`.
|
|
159
|
+
- 한 화면에 목록이 둘이면 `usePagedList` 를 둘 부른다 — 봉투가 prop 하나씩이라 서로 간섭하지 않는다.
|
|
160
|
+
|
|
101
161
|
### 2. API 클라이언트 (`api()`) 호출 (errata E-3 §C)
|
|
102
162
|
|
|
103
163
|
페이지와 무관한 JSON 액션(루트 데이터 경로 판단표 3행)은 `gaonjs/vue` 의 `api()` 로
|
|
@@ -202,9 +262,13 @@ import { Product } from '../../../domain/models/Product.js'
|
|
|
202
262
|
|
|
203
263
|
export default controller({
|
|
204
264
|
async index() {
|
|
205
|
-
const
|
|
265
|
+
const q = this.listQuery()
|
|
266
|
+
// 목록 계약(§1.1) — 봉투 하나를 this.lazy 로 싣고, 식별자는 그 안에서 String() 으로 못박는다.
|
|
206
267
|
return this.render('Products/Index', {
|
|
207
|
-
products:
|
|
268
|
+
products: this.lazy(async () => {
|
|
269
|
+
const r = await Product.latest().paginate(q.page, q.size)
|
|
270
|
+
return { ...r, rows: r.rows.map((p) => ({ id: String(p.id), name: p.name, price: p.price })) }
|
|
271
|
+
}),
|
|
208
272
|
})
|
|
209
273
|
},
|
|
210
274
|
})
|
|
@@ -413,10 +477,11 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
413
477
|
주던 행·열 관계가 그리드엔 없어서, `role="grid"/"row"/"columnheader"/"gridcell"` 과
|
|
414
478
|
`aria-sort` 가 없으면 스크린리더에는 표가 아니라 글자 더미로 읽힌다. `<table>` 은
|
|
415
479
|
본문 안에 끼우는 짧은 표에만 남긴다.
|
|
416
|
-
- **정렬 헤더는
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
480
|
+
- **정렬 헤더는 서버로 보낸다** (결정 464·465). 서버 페이지네이션(`paginate()`) 목록에서
|
|
481
|
+
**클라이언트 배열만 정렬하면 현재 쪽만** 정렬돼 "가격 높은 순" 이 전체가 아니라 그 쪽
|
|
482
|
+
안에서만 맞는 거짓말이 된다. 정렬은 목록 계약의 일부다 — 헤더 클릭은 `list.sort('price')`
|
|
483
|
+
(§1.1)로 `sort`·`dir` 을 쿼리에 실어 서버가 정렬한 쪽을 받는다. 클라 배열
|
|
484
|
+
`.sort()` 는 목록 **전체가 이미 클라에 있을 때만** 쓴다.
|
|
420
485
|
- **상태 문자열 → 색 매핑은 한 곳에 둔다** — `shared/lib/` 에 `statusTone(status)`
|
|
421
486
|
같은 함수 하나를 두고 배지가 그것만 쓴다. 화면마다 삼항 연산으로 색을 고르면 같은
|
|
422
487
|
"미처리" 가 화면에 따라 다른 색이 된다. 모르는 값은 중립색으로 떨어뜨려 화면이
|
|
@@ -658,6 +723,13 @@ async function runSearch(q: string) {
|
|
|
658
723
|
- **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
|
|
659
724
|
Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
|
|
660
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).
|
|
661
733
|
- **템플릿 `:key="String(p.id)"` 방어 금지** — 정규화는 컨트롤러 한 곳
|
|
662
734
|
(결정 37).
|
|
663
735
|
- **`v-html` 은 XSS 탈출구** — 사용자 입력을 넣지 않는다
|
|
@@ -710,6 +782,10 @@ async function runSearch(q: string) {
|
|
|
710
782
|
| 결정 166 | `api()` CSRF 자동 부착 = data-page `props.csrf`(결정 116 과 같은 단일 출처) · `<meta name="csrf-token">` 은 레거시 폴백(§2 · `packages/vue/src/api.ts`) |
|
|
711
783
|
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
|
|
712
784
|
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
785
|
+
| 결정 462 | Inertia 부분 리로드 서버 지원 — `router.reload/get` 의 `only` 가 **비로소 실제로 동작**한다(종전엔 서버가 헤더를 무시해 무동작) · 빠진 prop 은 클라가 이전 값으로 병합(`agents/web.md` §4.5) |
|
|
786
|
+
| 결정 463 | `this.lazy`/`this.optional` — 부분 리로드에서 지명 안 된 prop 은 전송도 평가도 안 된다 · `optional` 은 페이지 타입이 `T \| undefined`(`agents/web.md` §4.5) |
|
|
787
|
+
| 결정 464 | 목록 계약 — 봉투 prop 하나 + 표준 쿼리 이름 8종(`page`·`size`·`q`·`searchType`·`from`·`to`·`sort`·`dir`) · 클라/서버가 같은 이름을 쓴다(§1.1) |
|
|
788
|
+
| 결정 465 | `usePagedList(props, '<봉투키>')` — 이동·URL·디바운스·정렬·`only` 를 컴포저블이 소유 · 킷 `Pagination` 은 `v-model:page` 그대로(결정 106 무변경) · **서버 정렬이 계약에 들어와 §8 의 «정렬 헤더는 클라에 전체가 있을 때만» 규칙이 바뀌었다**(§1.1·§8) |
|
|
713
789
|
| 결정 128 | `useChannel` 자동 재연결(지수 백오프 1s·2s·5s·10s·지터 · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은 재연결 안 함 · `agents/realtime.md` §4) |
|
|
714
790
|
| 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
|
|
715
791
|
| 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
|
|
@@ -431,10 +431,7 @@ shared.locale // 로그인/로그아웃·플래시로
|
|
|
431
431
|
// ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
|
|
432
432
|
// ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
|
|
433
433
|
// ✅ 페이지네이션은 스코프 체인 종단 paginate — 컨트롤러는 여전히 한 줄(결정 119)
|
|
434
|
-
//
|
|
435
|
-
// const { page } = this.query({ _row: {} as { page?: string } })
|
|
436
|
-
// const result = await Post.searchPublished(term).latest().paginate(Number(page ?? 1), 20)
|
|
437
|
-
// return this.render('Posts/Index', { page: result }) // 통째로 안전(rows Serialized · 나머지 number)
|
|
434
|
+
// 목록 액션의 정본 형태는 §4.5(목록 계약)를 따른다 — 표준 쿼리 + 봉투 하나 + this.lazy.
|
|
438
435
|
```
|
|
439
436
|
|
|
440
437
|
### 4.4 클라이언트 IP · 헤더는 `this.request` (FastifyRequest 탈출구 · 결정 120)
|
|
@@ -457,6 +454,60 @@ shared.locale // 로그인/로그아웃·플래시로
|
|
|
457
454
|
위협 모델 · `compose.prod.yaml`).
|
|
458
455
|
- 잘못된 설정(빈 헤더 이름 등)은 **부팅 에러**로 즉시 잡힌다(→ 수리 안내 포함).
|
|
459
456
|
|
|
457
|
+
### 4.5 목록 화면 — 부분 리로드 · `this.lazy` · `this.listQuery` (결정 462~465)
|
|
458
|
+
|
|
459
|
+
목록 화면에서 **2쪽으로 넘어갈 때 다시 조회돼야 하는 것은 목록뿐**이다. 게시판 트리·
|
|
460
|
+
셀렉트 후보·통계 카드까지 매번 다시 뽑으면 쪽 이동이 첫 로드만큼 무거워진다.
|
|
461
|
+
Inertia 프로토콜의 **부분 리로드**가 그 자리인데, 서버가 무엇을 게을리 할지 알아야
|
|
462
|
+
성립한다 — 그 선언이 `this.lazy` 다.
|
|
463
|
+
|
|
464
|
+
**세 가지 선언, 세 가지 뜻:**
|
|
465
|
+
|
|
466
|
+
| 선언 | 값 평가 | 첫 로드 | 쪽 이동(미지명) | 이름으로 요청됨 | 페이지 타입 |
|
|
467
|
+
|---|---|---|---|---|---|
|
|
468
|
+
| `posts` (평범한 값) | 즉시(항상) | 포함 | **포함**(재계산·재전송) | 포함 | `T` |
|
|
469
|
+
| `this.lazy(() => …)` | 포함될 때만 | 포함 | **생략 + 클로저 미호출** | 포함 | `T` |
|
|
470
|
+
| `this.optional(() => …)` | 포함될 때만 | **미포함** | 미포함 | 포함 | `T \| undefined` |
|
|
471
|
+
|
|
472
|
+
- **`lazy` = 평가를 미룬다.** 첫 화면엔 늘 있으므로 페이지 타입이 `T` 로 유지된다
|
|
473
|
+
(옵셔널 체이닝이 안 늘어난다). **목록 화면의 정답**이다.
|
|
474
|
+
- **`optional` = 기본으로 안 보낸다.** "버튼을 눌러야 오는 상세·통계" 용. 타입이
|
|
475
|
+
`T | undefined` 라 그 성질이 에디터에서 바로 보인다.
|
|
476
|
+
- async 클로저는 1급이다(`paginate()` 가 async). 여러 lazy 는 **동시에** 평가된다.
|
|
477
|
+
|
|
478
|
+
**목록 계약** — 봉투는 **prop 하나**(`{ rows, total, page, pageCount, perPage }` =
|
|
479
|
+
`chain.paginate()` 반환 그대로)다. 4개 최상위 prop 으로 펴지 않는다: 이름이 하나라야
|
|
480
|
+
클라가 `only` 를 틀릴 수 없고(하나 빠뜨리면 쪽만 바뀌고 건수는 옛 값), 한 화면에
|
|
481
|
+
목록 둘을 놓을 수 있다.
|
|
482
|
+
|
|
483
|
+
**쿼리 이름은 고정**이다 — `page`·`size`·`q`·`searchType`·`from`·`to`·`sort`·`dir`.
|
|
484
|
+
`this.listQuery()` 가 정수화·클램프(`page ≥ 1` · `1 ≤ size ≤ 100`)까지 해서 읽고,
|
|
485
|
+
클라 `usePagedList` 가 **같은 이름으로** 싣는다. where/order 조립(의미)은 앱이 한다.
|
|
486
|
+
|
|
487
|
+
```ts controller-action
|
|
488
|
+
// apps/web/controllers/posts.ts — 쪽을 넘길 때 authors·stats 는 쿼리 자체가 안 나간다
|
|
489
|
+
async index() {
|
|
490
|
+
const q = this.listQuery()
|
|
491
|
+
return this.render('Posts/Index', {
|
|
492
|
+
posts: this.lazy(() => Post.latest().paginate(q.page, q.size)),
|
|
493
|
+
authors: this.lazy(() => User.select(['id', 'name']).limit(500).all()),
|
|
494
|
+
// 첫 로드엔 안 온다 — 화면이 이름으로 요청할 때만(결정 463).
|
|
495
|
+
stats: this.optional(() => Post.count()),
|
|
496
|
+
})
|
|
497
|
+
},
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
**경계 규칙(외우지 말고 알아만 둘 것):**
|
|
501
|
+
|
|
502
|
+
- 클라가 다른 페이지로 이동 중이면(`X-Inertia-Partial-Component` 불일치) 서버는
|
|
503
|
+
필터를 **무시하고 전량**을 보낸다 — 반쪽 페이지가 안 생긴다.
|
|
504
|
+
- 부분 응답에서 빠진 prop 은 클라가 **이전 값으로 채운다**(Inertia 프로토콜). 그래서
|
|
505
|
+
서버는 빼기만 하고, 무엇을 빼도 화면이 안 깨진다.
|
|
506
|
+
- 단 **`flash`·`errors` 는 항상 실린다** — 세션에서 한 번 읽고 지워지는 소비성 값이라
|
|
507
|
+
빼면 그대로 유실된다("저장했습니다" 가 영영 안 뜬다).
|
|
508
|
+
- **`this.lazy` 는 JSON 액션·`this.json` 에 쓸 수 없다**(게으를 이유가 없는 경로다) —
|
|
509
|
+
쓰면 응답 경계에서 수리 안내와 함께 throw 한다. 값을 그대로 `await` 해서 넘긴다.
|
|
510
|
+
|
|
460
511
|
### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
|
|
461
512
|
|
|
462
513
|
회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
|
|
@@ -741,6 +792,13 @@ export default controller({
|
|
|
741
792
|
빈 204 로 나간다. 렌더·리다이렉트·JSON 은 항상 `return` 과 함께 쓴다.
|
|
742
793
|
`gaon doctor` 의 **render-return** 검사가 이 패턴(호출만 하고 return 누락)을
|
|
743
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` 명령을 함께 낸다).
|
|
744
802
|
- **라우트 타깃 형식 불량은 부팅 에러다(결정 293)** — `r.get('/x', 'posts')`
|
|
745
803
|
처럼 `#액션` 을 빠뜨리면 이전엔 조용히 라우트가 사라져 무신호 404 였다.
|
|
746
804
|
이제 `routes()` 가 부팅에서 throw 한다(`'<컨트롤러>#<액션>'` 형식 필수).
|
|
@@ -788,6 +846,10 @@ export default controller({
|
|
|
788
846
|
| 결정 117 | render props 에 예약 공유 키 = 컴파일 에러 + 런타임 방어(자동 주입값 조용한 덮어쓰기 금지 · §4.2) |
|
|
789
847
|
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps → 모든 렌더 자동 주입 · 코어 3종 예약(덮으면 throw) · 선언 병합 타입 · hidden 미유출 · useShared 로 읽기(§4.2) |
|
|
790
848
|
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
849
|
+
| 결정 462 | Inertia **부분 리로드** 서버 지원 — `X-Inertia-Partial-Data`/`-Except` 를 존중해 요청된 prop 만 전송 · `-Component` 불일치면 전량 · **소비성 prop(flash·errors)만 항상 포함** · 버전 불일치 409 는 필터보다 먼저(§4.5) |
|
|
850
|
+
| 결정 463 | 평가 시점/포함 여부 분리 — `this.lazy(fn)`(첫 로드 포함 · 미지명 시 생략 + **클로저 미호출**) · `this.optional(fn)`(기본 미포함 · `T \| undefined`) · **원시 함수 prop 금지**(컴파일 에러 + 런타임 수리 안내 — 종전엔 조용히 사라졌다) · JSON 경계에 새면 throw(§4.5) |
|
|
851
|
+
| 결정 464 | 목록 계약 — 봉투 **prop 하나**(`{rows,total,page,pageCount,perPage}`) · 표준 쿼리 이름 8종 · `this.listQuery()`(정수화·클램프 `size ≤ 100`)(§4.5) |
|
|
852
|
+
| 결정 465 | 클라 목록 표면 = `usePagedList(props, '<봉투키>')`(`gaonjs/vue`) — `only`·URL·디바운스·정렬을 컴포저블이 소유 · 킷 `Pagination` 은 `v-model:page` 그대로(결정 106 무변경 · `agents/frontend.md`) |
|
|
791
853
|
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
792
854
|
| 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 검사는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내 · 부착은 결정 342 로 자동화) |
|
|
793
855
|
| 결정 342 | CSRF 부착 The One Way — `useForm`/`router` 상태 변경 visit 에 프레임웍이 `X-CSRF-Token` 자동 부착(라이브 페이지 props 출처 · 결정 341 · api() 와 단일 출처) · 수동 `_csrf` 바디/헤더 보일러플레이트 제거(스캐폴드 동기) · 명시 헤더는 존중(탈출구) |
|
|
@@ -807,6 +869,7 @@ export default controller({
|
|
|
807
869
|
| 결정 338 | `gaon g auth --jwt --app <api>` — API 앱 토큰 스캐폴드(발급/재발급/내 정보 · 페이지·가입 없음 · `<APP>_JWT_SECRET` 시드 · §6) |
|
|
808
870
|
| 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }`(생략 = 전역 상속 · rate limit 버킷은 앱 단위 · `agents/security.md` §1) |
|
|
809
871
|
| 결정 340 | doctor `render-return` — 응답 호출만 하고 return 누락 = 무신호 204 경고(함정 §알려진 함정) |
|
|
872
|
+
| 결정 466 | `gaon g controller` render 타깃 = 복수 리소스 폴더(`Posts/Index`) · doctor `render-page-exists` 로 없는 페이지 render 를 에러(함정 §알려진 함정) |
|
|
810
873
|
| 결정 389 | 앱 스코프 보안 override 는 전역과 **필드 병합** — 부분 override 가 나머지 필드를 코어 기본으로 리셋하지 않음 · CORS 는 origin 미명시 시 fail-closed(`agents/security.md` §1) |
|
|
811
874
|
| 결정 390 | Inertia 렌더의 `Vary: X-Inertia` 는 기존 Vary(CORS `Origin` 등)에 **병합**(치환 아님) |
|
|
812
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.
|
|
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",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
33
|
"typescript": "^5.9.0",
|
|
34
34
|
"vite": "^7.0.0",
|
|
35
|
-
"@gaonjs/config": "0.26.2",
|
|
36
35
|
"@gaonjs/async": "0.22.0",
|
|
37
|
-
"@gaonjs/
|
|
36
|
+
"@gaonjs/config": "0.27.0",
|
|
38
37
|
"@gaonjs/core": "0.3.0",
|
|
39
38
|
"@gaonjs/mail": "0.5.4",
|
|
40
|
-
"@gaonjs/
|
|
41
|
-
"@gaonjs/
|
|
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})\""
|