@gaonjs/cli 0.24.0 → 0.26.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/test.js +70 -0
- package/dist/db/migrate.d.ts +3 -0
- package/dist/db/migrate.js +1 -0
- package/dist/db/resolve.d.ts +7 -1
- package/dist/db/resolve.js +5 -1
- package/dist/doctor/page-layout-breakpoint.d.ts +8 -0
- package/dist/doctor/page-layout-breakpoint.js +94 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/ui-kit-wiring.js +11 -7
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +6 -1
- package/dist/index.js +2 -2
- package/dist/templates/auth/Dashboard.vue.tpl +7 -7
- package/dist/templates/auth/Login.vue.tpl +11 -11
- package/dist/templates/auth/Signup.vue.tpl +11 -11
- package/dist/templates/auth/app.ts.tpl +29 -0
- package/dist/templates/auth/server.ts.tpl +14 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/agents/data.md.tpl +39 -2
- package/dist/templates/project/agents/frontend.md.tpl +73 -26
- package/dist/templates/project/agents/testing.md.tpl +53 -6
- package/dist/templates/project/agents/web.md.tpl +42 -3
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
- package/dist/templates/project/apps/web/style.css.tpl +62 -41
- package/dist/templates/project/test/setup.ts.tpl +24 -0
- package/dist/templates/project/tsconfig.json.tpl +5 -1
- package/dist/templates/project/vite.config.ts.tpl +16 -0
- package/dist/templates/project/vitest.config.ts.tpl +23 -0
- package/dist/templates/ui-kit/EmptyState.vue.tpl +23 -0
- package/dist/templates/ui-kit/PageHeader.vue.tpl +25 -0
- package/dist/templates/ui-kit/PageShell.vue.tpl +27 -0
- package/dist/templates/ui-kit/Pagination.vue.tpl +60 -0
- package/dist/templates/ui-kit/utils.ts.tpl +1 -1
- package/dist/uikit.d.ts +4 -4
- package/dist/uikit.js +56 -22
- package/package.json +5 -5
package/dist/commands/test.js
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
import { spawn } from 'node:child_process';
|
|
23
23
|
import { existsSync, readFileSync } from 'node:fs';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
|
+
import { loadGaonConfig } from '@gaonjs/config';
|
|
26
|
+
import { deriveTestDatabaseConfig, ensureTestDatabaseExists, destroyAllConnections, } from '@gaonjs/data';
|
|
27
|
+
import { registerTsResolve } from '../tsResolve.js';
|
|
28
|
+
import { runDbMigrate } from '../db/migrate.js';
|
|
25
29
|
/** 사용자 프로젝트에 `test` 스크립트가 있는지. */
|
|
26
30
|
function hasTestScript(cwd) {
|
|
27
31
|
const pkgPath = join(cwd, 'package.json');
|
|
@@ -52,6 +56,66 @@ function scopeArgs(scope) {
|
|
|
52
56
|
}
|
|
53
57
|
return [];
|
|
54
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* 결정 111: 테스트 전용 DB 자동 준비 — gaon.config 의 각 커넥션을 `<db>_test` 로
|
|
61
|
+
* 파생해 없으면 만들고(CREATE DATABASE) 마이그레이션을 적용한다. 테스트 프로세스는
|
|
62
|
+
* test/setup.ts 의 `connectTestDatabase()` 로 같은 DB 에 붙고, 매 테스트 뒤
|
|
63
|
+
* `truncateAll()` 로 격리한다(§9 실 인프라 · truncate 격리 근거는 결정 111).
|
|
64
|
+
*
|
|
65
|
+
* scope='unit' 은 실 인프라가 필요 없으므로 건너뛴다. config 가 없거나 db 커넥션이
|
|
66
|
+
* 없으면 조용히 스킵(순수 vitest 위임). DB 접속 실패는 수리 안내와 함께 실패한다.
|
|
67
|
+
*/
|
|
68
|
+
async function provisionTestDatabases(cwd, json) {
|
|
69
|
+
registerTsResolve();
|
|
70
|
+
let config;
|
|
71
|
+
try {
|
|
72
|
+
config = await loadGaonConfig(cwd);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return true; // config 로드 실패 = 최소 프로젝트 · DB 준비 스킵
|
|
76
|
+
}
|
|
77
|
+
const dbs = config.db ? Object.entries(config.db) : [];
|
|
78
|
+
if (dbs.length === 0)
|
|
79
|
+
return true;
|
|
80
|
+
const prepared = [];
|
|
81
|
+
try {
|
|
82
|
+
for (const [key, cfg] of dbs) {
|
|
83
|
+
const testCfg = deriveTestDatabaseConfig(cfg);
|
|
84
|
+
await ensureTestDatabaseExists(testCfg);
|
|
85
|
+
const res = await runDbMigrate({
|
|
86
|
+
cwd,
|
|
87
|
+
dbKey: key,
|
|
88
|
+
json: false,
|
|
89
|
+
dryRun: false,
|
|
90
|
+
connectionOverride: testCfg,
|
|
91
|
+
});
|
|
92
|
+
if (res.exitCode !== 0) {
|
|
93
|
+
if (!json)
|
|
94
|
+
process.stderr.write(` ✗ 테스트 DB '${key}' 마이그레이션 실패\n${res.text}\n`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
prepared.push(key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
102
|
+
const hint = ` ✗ 테스트 DB 준비 실패: ${msg}\n` +
|
|
103
|
+
` → DB 가 떠 있는지 확인하세요(docker compose up -d db). 테스트는 실 인프라가 필요합니다(§9).\n`;
|
|
104
|
+
if (json)
|
|
105
|
+
process.stdout.write(JSON.stringify({ ok: false, kind: 'provision', error: msg }) + '\n');
|
|
106
|
+
else
|
|
107
|
+
process.stderr.write(hint);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await destroyAllConnections();
|
|
112
|
+
}
|
|
113
|
+
if (json)
|
|
114
|
+
process.stdout.write(JSON.stringify({ kind: 'provisioned', dbs: prepared }) + '\n');
|
|
115
|
+
else
|
|
116
|
+
process.stdout.write(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
55
119
|
/**
|
|
56
120
|
* `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
|
|
57
121
|
* pnpm test 는 pnpm 관례상 `pnpm test -- <args>` 로 넘겨야 vitest 까지 도달.
|
|
@@ -60,6 +124,12 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
60
124
|
const cwd = opts.cwd ?? process.cwd();
|
|
61
125
|
const scope = opts.scope ?? 'all';
|
|
62
126
|
const json = opts.json ?? false;
|
|
127
|
+
// 결정 111: unit 이 아니면 실행 전 테스트 DB 를 준비한다(생성 + 마이그레이션).
|
|
128
|
+
if (scope !== 'unit') {
|
|
129
|
+
const ok = await provisionTestDatabases(cwd, json);
|
|
130
|
+
if (!ok)
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
63
133
|
const scopeExtras = scopeArgs(scope);
|
|
64
134
|
const passthrough = [...scopeExtras, ...args];
|
|
65
135
|
let cmd;
|
package/dist/db/migrate.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ConnectionConfig } from '@gaonjs/data';
|
|
1
2
|
import { listMigrationFiles } from './replay.js';
|
|
2
3
|
export interface DbMigrateOptions {
|
|
3
4
|
readonly cwd: string;
|
|
@@ -7,6 +8,8 @@ export interface DbMigrateOptions {
|
|
|
7
8
|
/** `gaon db migrate down` — 가장 최근 이력 한 건 롤백. */
|
|
8
9
|
readonly down?: boolean;
|
|
9
10
|
readonly configPath?: string;
|
|
11
|
+
/** 명시 커넥션 오버라이드(결정 111 · gaon test 의 <db>_test). */
|
|
12
|
+
readonly connectionOverride?: ConnectionConfig;
|
|
10
13
|
}
|
|
11
14
|
export interface DbMigrateResult {
|
|
12
15
|
readonly exitCode: number;
|
package/dist/db/migrate.js
CHANGED
package/dist/db/resolve.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type TableDef } from '@gaonjs/data';
|
|
1
|
+
import { type ConnectionConfig, type TableDef } from '@gaonjs/data';
|
|
2
2
|
import type { Kysely } from 'kysely';
|
|
3
3
|
import { type Dialect, type AdapterName } from '@gaonjs/data';
|
|
4
4
|
export interface ResolveDbOptions {
|
|
@@ -6,6 +6,12 @@ export interface ResolveDbOptions {
|
|
|
6
6
|
readonly dbKey: string;
|
|
7
7
|
/** --config <path> 로 사용자가 지정한 config 경로. 없으면 cwd 관례. */
|
|
8
8
|
readonly configPath?: string;
|
|
9
|
+
/**
|
|
10
|
+
* 명시 커넥션 설정 오버라이드(결정 111). 주면 config.db·GAON_DATABASE_URL 보다
|
|
11
|
+
* 우선한다 — `gaon test` 가 <db>_test 로 마이그레이션을 돌릴 때 쓴다. 스키마 스캔
|
|
12
|
+
* (tables)은 여전히 config/cwd 관례를 따른다.
|
|
13
|
+
*/
|
|
14
|
+
readonly connectionOverride?: ConnectionConfig;
|
|
9
15
|
}
|
|
10
16
|
export interface ResolvedDbTarget {
|
|
11
17
|
readonly dbKey: string;
|
package/dist/db/resolve.js
CHANGED
|
@@ -92,7 +92,11 @@ export async function resolveDbTarget(opts) {
|
|
|
92
92
|
const dbKey = opts.dbKey;
|
|
93
93
|
const cfg = config.db?.[dbKey];
|
|
94
94
|
let connCfg;
|
|
95
|
-
if (
|
|
95
|
+
if (opts.connectionOverride) {
|
|
96
|
+
// 결정 111: 명시 오버라이드(gaon test 의 <db>_test) 최우선.
|
|
97
|
+
connCfg = opts.connectionOverride;
|
|
98
|
+
}
|
|
99
|
+
else if (cfg) {
|
|
96
100
|
connCfg = cfg;
|
|
97
101
|
}
|
|
98
102
|
else if (dbKey === 'main') {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에서 레이아웃 브레이크포인트 사용 위치(토큰·줄)를 모은다(단위 테스트 진입점). */
|
|
3
|
+
export declare function usesLayoutBreakpoint(source: string): {
|
|
4
|
+
token: string;
|
|
5
|
+
line: number;
|
|
6
|
+
}[];
|
|
7
|
+
/** apps/<앱>/pages 를 훑어 페이지 레이아웃 브레이크포인트 사용을 경고로 낸다. */
|
|
8
|
+
export declare function checkPageLayoutBreakpoint(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 페이지 레이아웃 브레이크포인트 안내 (결정 107)
|
|
2
|
+
//
|
|
3
|
+
// 폭·여백·열 수 같은 레이아웃 반응형은 UI 킷 블록(PageShell·PageHeader …)이
|
|
4
|
+
// 소유한다(결정 105·106·107). 페이지 파일이 레이아웃 브레이크포인트를 루트에서
|
|
5
|
+
// 직접 쓰면 반응형 규칙이 여러 곳에 흩어져, "정답이 하나" 가 무너진다. 이 검사는
|
|
6
|
+
// 그 사용을 **안내 경고(warning)** 로만 낸다 — 강제(error)가 아니다. 킷에 없는
|
|
7
|
+
// 표현이면 Tailwind 유틸을 직접 써도 되므로(탈출구), 오탐을 줄이려 신호가 확실한
|
|
8
|
+
// 레이아웃 유틸(반응형 flex-direction·grid 열 수)만 좁게 잡는다.
|
|
9
|
+
//
|
|
10
|
+
// 대상: apps/<앱>/pages/**/*.vue (페이지 파일만). shared/components/ui 의 킷 블록은
|
|
11
|
+
// 반응형을 소유하므로 검사하지 않는다(그게 이 규칙의 목적).
|
|
12
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
13
|
+
import { join, relative } from 'node:path';
|
|
14
|
+
/**
|
|
15
|
+
* 좁게 잡는 레이아웃 반응형 패턴: 반응형 접두사 + (flex-row/col · grid-cols-N/[).
|
|
16
|
+
* `sm:hidden`·`sm:text-lg`·`sm:px-6` 같은 흔한 반응형(표시·타이포·여백)은 잡지
|
|
17
|
+
* 않는다 — 오탐을 줄이려 페이지 레이아웃을 뒤집는 확실한 신호만 본다.
|
|
18
|
+
*/
|
|
19
|
+
const LAYOUT_BREAKPOINT = /\b(sm|md|lg|xl|2xl):(flex-(?:row|col)(?:-reverse)?|grid-cols-(?:\d+|\[))/;
|
|
20
|
+
/** 소스에서 레이아웃 브레이크포인트 사용 위치(토큰·줄)를 모은다(단위 테스트 진입점). */
|
|
21
|
+
export function usesLayoutBreakpoint(source) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const lines = source.split('\n');
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const m = LAYOUT_BREAKPOINT.exec(lines[i]);
|
|
26
|
+
if (m)
|
|
27
|
+
out.push({ token: m[0], line: i + 1 });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** apps/<앱>/pages 를 훑어 페이지 레이아웃 브레이크포인트 사용을 경고로 낸다. */
|
|
32
|
+
export async function checkPageLayoutBreakpoint(cwd) {
|
|
33
|
+
const appsDir = join(cwd, 'apps');
|
|
34
|
+
const issues = [];
|
|
35
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
36
|
+
const pagesDir = join(appsDir, app, 'pages');
|
|
37
|
+
for (const abs of await walkVueFiles(pagesDir)) {
|
|
38
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
39
|
+
const hits = usesLayoutBreakpoint(source);
|
|
40
|
+
if (hits.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const rel = relative(cwd, abs);
|
|
43
|
+
const first = hits[0];
|
|
44
|
+
issues.push({
|
|
45
|
+
rule: 'page-layout-breakpoint',
|
|
46
|
+
level: 'warning',
|
|
47
|
+
file: rel,
|
|
48
|
+
line: first.line,
|
|
49
|
+
message: `${rel} (line ${first.line})\n` +
|
|
50
|
+
` 페이지가 레이아웃 브레이크포인트('${first.token}'${hits.length > 1 ? ` 외 ${hits.length - 1}건` : ''})를 직접 씁니다.\n` +
|
|
51
|
+
` 반응형(폭·여백·열 수)은 UI 킷 블록이 책임집니다(결정 107).\n` +
|
|
52
|
+
`→ shared/components/ui 의 블록(PageShell·PageHeader 등)으로 감싸 반응형을 킷에 두거나,\n` +
|
|
53
|
+
` 킷에 없는 표현이면 그대로 둬도 됩니다 — 이 경고는 강제가 아닙니다(탈출구 유지).`,
|
|
54
|
+
detail: { token: first.token, count: hits.length },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { rule: 'page-layout-breakpoint', issues };
|
|
59
|
+
}
|
|
60
|
+
/** 디렉터리 트리에서 .vue 파일 절대경로를 모은다(테스트·선언 제외 불필요 — .vue 만). */
|
|
61
|
+
async function walkVueFiles(dir) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const walk = async (d) => {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = (await readdir(d, { withFileTypes: true }));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
const abs = join(d, e.name);
|
|
73
|
+
if (e.isDirectory()) {
|
|
74
|
+
if (e.name === 'node_modules' || e.name === '.gaon' || e.name === 'dist')
|
|
75
|
+
continue;
|
|
76
|
+
await walk(abs);
|
|
77
|
+
}
|
|
78
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
79
|
+
out.push(abs);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await walk(dir);
|
|
84
|
+
return out.sort();
|
|
85
|
+
}
|
|
86
|
+
async function safeListDirs(dir) {
|
|
87
|
+
try {
|
|
88
|
+
const entries = (await readdir(dir, { withFileTypes: true }));
|
|
89
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
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-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';
|
|
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';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// @gaonjs/cli · doctor · UI 킷 배선 검사 (결정 76)
|
|
1
|
+
// @gaonjs/cli · doctor · UI 킷 배선 검사 (결정 76 · 결정 105)
|
|
2
2
|
//
|
|
3
|
-
// 앱이 UI 킷 컴포넌트(
|
|
4
|
-
// Tailwind 배선(apps/<앱>/style.css 의 @tailwind +
|
|
5
|
-
// 없으면, UI 킷이 쓰는 유틸 클래스(bg-primary·
|
|
6
|
-
// 컴포넌트가 스타일 없이 렌더된다 — 컴파일은
|
|
7
|
-
// 조용한 런타임 파손이다(결정 74·75·76).
|
|
3
|
+
// 앱이 UI 킷 컴포넌트(@shared/components/ui/* — 결정 105 로 shared 이전)를
|
|
4
|
+
// import 하는데 그 앱에 Tailwind 배선(apps/<앱>/style.css 의 @tailwind +
|
|
5
|
+
// main.ts 의 style.css import)이 없으면, UI 킷이 쓰는 유틸 클래스(bg-primary·
|
|
6
|
+
// rounded-lg …)가 펼쳐지지 않아 컴포넌트가 스타일 없이 렌더된다 — 컴파일은
|
|
7
|
+
// 통과하므로 check 로는 안 잡히는 조용한 런타임 파손이다(결정 74·75·76·105).
|
|
8
|
+
// 이 검사가 그 상태를 경고로 낸다. 킷 위치와 무관하게 import 경로에 남는
|
|
9
|
+
// 'components/ui/' 로 사용처를 잡으므로 @shared alias 도 그대로 감지된다.
|
|
8
10
|
//
|
|
9
11
|
// `gaon new`(web)·`gaon g app` 은 이제 배선을 함께 심으므로 정상 경로에서는
|
|
10
12
|
// 걸리지 않는다. 이 규칙은 배선을 지웠거나 구버전 스캐폴드에서 만든 앱을 잡는
|
|
@@ -44,7 +46,9 @@ export async function checkUiKitWiring(cwd) {
|
|
|
44
46
|
/** 앱 디렉터리를 재귀 스캔해 UI 킷을 import 하는 첫 .vue/.ts 파일(cwd 상대)을 찾는다. */
|
|
45
47
|
async function findUiKitImporter(appDir, cwd) {
|
|
46
48
|
for (const abs of await walkSources(appDir)) {
|
|
47
|
-
//
|
|
49
|
+
// 킷은 이제 shared/ 라 apps/ 스캔엔 안 들어오지만, 결정 105 이전의 앱별
|
|
50
|
+
// 사본(apps/<앱>/components/ui)이 남아 있으면 그 컴포넌트끼리의 상호 import 를
|
|
51
|
+
// 사용처로 오인하지 않도록 제외한다(마이그레이션 과도기 방어).
|
|
48
52
|
if (abs.includes(`${join(appDir, 'components', 'ui')}`))
|
|
49
53
|
continue;
|
|
50
54
|
const source = await readFile(abs, 'utf8').catch(() => '');
|
package/dist/doctor.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js
|
|
|
19
19
|
export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
|
|
20
20
|
export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
|
|
21
21
|
export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
|
|
22
|
+
export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
|
|
22
23
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
23
24
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
24
25
|
export interface DoctorCommandOptions {
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 22 검사를 조립한다:
|
|
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 규칙)
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
|
|
24
24
|
* 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
|
|
25
25
|
* 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
|
|
26
|
+
* 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
|
|
26
27
|
*
|
|
27
28
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
28
29
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -56,6 +57,7 @@ import { checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
|
56
57
|
import { checkInternalAnchor } from './doctor/internal-anchor.js';
|
|
57
58
|
import { checkPagePropsDestructure } from './doctor/pageprops-destructure.js';
|
|
58
59
|
import { checkAsyncOffload } from './doctor/async-offload.js';
|
|
60
|
+
import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
|
|
59
61
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
60
62
|
import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
61
63
|
import { makeResult, } from './doctor/types.js';
|
|
@@ -78,6 +80,7 @@ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js
|
|
|
78
80
|
export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
|
|
79
81
|
export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
|
|
80
82
|
export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
|
|
83
|
+
export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
|
|
81
84
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
82
85
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
83
86
|
/**
|
|
@@ -105,6 +108,7 @@ const ALL_RULES = [
|
|
|
105
108
|
'internal-anchor',
|
|
106
109
|
'pageprops-destructure',
|
|
107
110
|
'async-offload',
|
|
111
|
+
'page-layout-breakpoint',
|
|
108
112
|
];
|
|
109
113
|
const CHECKERS = {
|
|
110
114
|
'response-mixing': checkResponseMixing,
|
|
@@ -128,6 +132,7 @@ const CHECKERS = {
|
|
|
128
132
|
'internal-anchor': checkInternalAnchor,
|
|
129
133
|
'pageprops-destructure': checkPagePropsDestructure,
|
|
130
134
|
'async-offload': checkAsyncOffload,
|
|
135
|
+
'page-layout-breakpoint': checkPageLayoutBreakpoint,
|
|
131
136
|
};
|
|
132
137
|
/**
|
|
133
138
|
* 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/index.js
CHANGED
|
@@ -100,8 +100,8 @@ function renderHelp(version = VERSION) {
|
|
|
100
100
|
" gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
|
|
101
101
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
102
102
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
103
|
-
" gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
|
|
104
|
-
" gaon doctor 정적 검사 (
|
|
103
|
+
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
|
|
104
|
+
" gaon doctor 정적 검사 (22 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트)",
|
|
105
105
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
106
106
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
107
107
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, router } from 'gaonjs/vue'
|
|
3
|
-
import Card from '
|
|
4
|
-
import CardHeader from '
|
|
5
|
-
import CardTitle from '
|
|
6
|
-
import CardDescription from '
|
|
7
|
-
import CardContent from '
|
|
8
|
-
import CardFooter from '
|
|
9
|
-
import Button from '
|
|
3
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '@shared/components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '@shared/components/ui/CardContent.vue'
|
|
8
|
+
import CardFooter from '@shared/components/ui/CardFooter.vue'
|
|
9
|
+
import Button from '@shared/components/ui/Button.vue'
|
|
10
10
|
|
|
11
11
|
// dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
|
|
12
12
|
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
3
|
-
import Card from '
|
|
4
|
-
import CardHeader from '
|
|
5
|
-
import CardTitle from '
|
|
6
|
-
import CardDescription from '
|
|
7
|
-
import CardContent from '
|
|
8
|
-
import Form from '
|
|
9
|
-
import FormField from '
|
|
10
|
-
import Input from '
|
|
11
|
-
import Button from '
|
|
12
|
-
import Alert from '
|
|
13
|
-
import AlertDescription from '
|
|
3
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '@shared/components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '@shared/components/ui/CardContent.vue'
|
|
8
|
+
import Form from '@shared/components/ui/Form.vue'
|
|
9
|
+
import FormField from '@shared/components/ui/FormField.vue'
|
|
10
|
+
import Input from '@shared/components/ui/Input.vue'
|
|
11
|
+
import Button from '@shared/components/ui/Button.vue'
|
|
12
|
+
import Alert from '@shared/components/ui/Alert.vue'
|
|
13
|
+
import AlertDescription from '@shared/components/ui/AlertDescription.vue'
|
|
14
14
|
|
|
15
15
|
// 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2). pageProps 는
|
|
16
16
|
// 반응형이라 변수로 받아 props.x 로 접근한다 — 구조분해 금지(결정 99). 로그인
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
3
|
-
import Card from '
|
|
4
|
-
import CardHeader from '
|
|
5
|
-
import CardTitle from '
|
|
6
|
-
import CardDescription from '
|
|
7
|
-
import CardContent from '
|
|
8
|
-
import Form from '
|
|
9
|
-
import FormField from '
|
|
10
|
-
import Input from '
|
|
11
|
-
import Button from '
|
|
12
|
-
import Alert from '
|
|
13
|
-
import AlertDescription from '
|
|
3
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '@shared/components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '@shared/components/ui/CardContent.vue'
|
|
8
|
+
import Form from '@shared/components/ui/Form.vue'
|
|
9
|
+
import FormField from '@shared/components/ui/FormField.vue'
|
|
10
|
+
import Input from '@shared/components/ui/Input.vue'
|
|
11
|
+
import Button from '@shared/components/ui/Button.vue'
|
|
12
|
+
import Alert from '@shared/components/ui/Alert.vue'
|
|
13
|
+
import AlertDescription from '@shared/components/ui/AlertDescription.vue'
|
|
14
14
|
|
|
15
15
|
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
16
16
|
const props = pageProps<'{{APP_NAME}}:registration#new'>()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 웹 앱 팩토리 — gaon g auth 스캐폴드. createWebApp 으로 세션·인증을 배선한다.
|
|
2
|
+
import { createApp, type AppSessionOptions } from 'gaonjs/web'
|
|
3
|
+
import appRoutes from './routes.js'
|
|
4
|
+
import session from './controllers/session.js'
|
|
5
|
+
import registration from './controllers/registration.js'
|
|
6
|
+
import dashboard from './controllers/dashboard.js'
|
|
7
|
+
import { loadUser } from './auth.js'
|
|
8
|
+
|
|
9
|
+
export interface WebAppDeps {
|
|
10
|
+
/** 세션 설정 — { redisUrl, secret } (또는 redis 인스턴스). */
|
|
11
|
+
readonly session: AppSessionOptions
|
|
12
|
+
/** 서명 쿠키/CSRF 용 비밀. */
|
|
13
|
+
readonly cookieSecret?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createWebApp(deps: WebAppDeps) {
|
|
17
|
+
return createApp({
|
|
18
|
+
apps: [
|
|
19
|
+
{
|
|
20
|
+
name: '{{APP_NAME}}',
|
|
21
|
+
routes: appRoutes,
|
|
22
|
+
controllers: { session, registration, dashboard },
|
|
23
|
+
session: deps.session,
|
|
24
|
+
auth: { loadUser, loginRedirect: '/session/new' },
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
cookieSecret: deps.cookieSecret,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// 서버 진입점 — gaon g auth 스캐폴드. `node dist/server.js` 로 실행.
|
|
2
|
+
import { createWebApp } from './apps/{{APP_NAME}}/app.js'
|
|
3
|
+
|
|
4
|
+
const app = await createWebApp({
|
|
5
|
+
session: {
|
|
6
|
+
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
|
7
|
+
secret: process.env.SESSION_SECRET ?? 'change-me-to-a-32+char-random-secret!!',
|
|
8
|
+
},
|
|
9
|
+
cookieSecret: process.env.COOKIE_SECRET,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const port = Number(process.env.PORT ?? 3000)
|
|
13
|
+
await app.listen({ port, host: '0.0.0.0' })
|
|
14
|
+
console.log(`web 앱이 http://localhost:${port} 에서 실행 중입니다.`)
|
|
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
104
104
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
105
105
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
106
106
|
|
|
107
|
-
### 2.2 `gaon doctor` 검사
|
|
107
|
+
### 2.2 `gaon doctor` 검사 22종
|
|
108
108
|
|
|
109
109
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
110
110
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -127,6 +127,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
127
127
|
19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
|
|
128
128
|
20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
|
|
129
129
|
21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
|
|
130
|
+
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
130
131
|
|
|
131
132
|
## 3. 로직 배치 One Way 판단표
|
|
132
133
|
|
|
@@ -187,7 +188,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
187
188
|
```bash
|
|
188
189
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
189
190
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
190
|
-
gaon doctor # 정적 검사
|
|
191
|
+
gaon doctor # 정적 검사 22종 (§2.2)
|
|
191
192
|
```
|
|
192
193
|
|
|
193
194
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -179,9 +179,9 @@ export const posts = table('posts', {
|
|
|
179
179
|
| `where` | `(col, op, val?)` | `Chain` | op 에 따라 val 형태 강제 (위 표) |
|
|
180
180
|
| `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약 |
|
|
181
181
|
| `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습 · `model.ts:245-262`) |
|
|
182
|
-
| `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) |
|
|
182
|
+
| `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) · 정렬 뒤 PK 타이브레이커 자동 부가(결정 110) |
|
|
183
183
|
| `reorder` | `(col, dir?)` | `Chain` | 기존 정렬 전부 버리고 재지정 |
|
|
184
|
-
| `latest` | `()` | `Chain` | `orderBy('createdAt', 'desc')` 고정 축약 |
|
|
184
|
+
| `latest` | `()` | `Chain` | `orderBy('createdAt', 'desc')` 고정 축약 · id 타이브레이커로 결정적(결정 110) |
|
|
185
185
|
| `limit` | `(n)` | `Chain` | |
|
|
186
186
|
| `offset` | `(n)` | `Chain` | 페이지네이션 = `orderBy·offset·limit` 조합 |
|
|
187
187
|
| `first` | `()` | `Promise<Rec \| undefined>` | 자동 `limit 1` |
|
|
@@ -395,6 +395,37 @@ export const Post = model(posts, {
|
|
|
395
395
|
})
|
|
396
396
|
```
|
|
397
397
|
|
|
398
|
+
### 8.1 스키마 파생 폼 — `Model.form` · `Model.form.pick()` (결정 104)
|
|
399
|
+
|
|
400
|
+
모든 모델은 `Model.form` 으로 **스키마 파생 폼**을 노출한다 — 컨트롤러의
|
|
401
|
+
`this.params(Model.form)` 에 넘기면 컬럼 타입으로 런타임 강제 변환·검증
|
|
402
|
+
(필수 강제 · 스키마 밖 키 제거 = 대량 할당 차단)까지 한다. 컨트롤러 쪽
|
|
403
|
+
사용법(폼 모양 판단·라우트 파라미터 병합)은 `agents/web.md` §3 이 정본이다.
|
|
404
|
+
|
|
405
|
+
**일부 컬럼만 검증해서 받으려면 `pick()`** — 지정한 컬럼만 담은 **새 폼**을
|
|
406
|
+
돌려준다(원 폼 불변). 컬럼 타입·검증·기본값 정보가 그대로 따라오므로,
|
|
407
|
+
"검증되는 부분 폼"이 필요할 때 애드혹 `{ _row: {} as T }`(검증 없음) 대신 쓴다.
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// routes: r.post('/posts/:postId/comments', 'comments#create')
|
|
411
|
+
// comments 스키마에서 postId·author·body 만 — :postId 는 라우트에서 자동 병합(결정 95).
|
|
412
|
+
async create() {
|
|
413
|
+
const data = this.params(Comment.form.pick('postId', 'author', 'body'))
|
|
414
|
+
// data: { postId: bigint; author: string; body: string } — 안 고른 컬럼은 요구하지 않는다.
|
|
415
|
+
const comment = await Comment.create(data)
|
|
416
|
+
return this.redirect(`/posts/${String(data.postId)}`)
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
- `pick()` 결과도 폼이다 — 부분집합으로 다시 `pick()` 할 수 있다.
|
|
421
|
+
- 스키마에 없는 컬럼을 지정하면 즉시 throw(수리 안내 포함) · 빈
|
|
422
|
+
`pick()` 도 throw · 중복 지정은 조용히 하나로 합친다.
|
|
423
|
+
- **정적 default 컬럼은 빈 입력이면 그 default 로 채워진다**(결정 108) —
|
|
424
|
+
`t.string().default('N/A')` 같은 컬럼을 pick 해 빈 값('')·미전송으로 받으면
|
|
425
|
+
런타임이 default 를 채운다(폼 타입이 required 인데 undefined 로 새지 않는다).
|
|
426
|
+
동적 default(`now()`·`gen_random_uuid()`·bigserial)는 채우지 않고 DB 가 채운다.
|
|
427
|
+
- `omit`·`extend`·`merge` 는 **없다** — 폼 변형은 `pick()` 하나가 The One Way.
|
|
428
|
+
|
|
398
429
|
### 9. 서비스 (`service()`) — 트랜잭션 작업 흐름 (정본 §5.3 · `packages/data/src/service.ts`)
|
|
399
430
|
|
|
400
431
|
로직 배치의 One Way 규칙은 루트 `AGENTS.md` 판단표가 정본이다 (정본 §5.3):
|
|
@@ -601,6 +632,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
601
632
|
doctor **model-filename** 이 잡는다(`--fix` 지원 · 결정 32·46).
|
|
602
633
|
- **잡·이벤트 발행을 트랜잭션과 정합시키려면** `afterCommit()`(service 안)
|
|
603
634
|
또는 아웃박스(`agents/async.md`) — 커밋 전 발행은 롤백 시 유령 부수효과.
|
|
635
|
+
- **폼 변형은 `pick()` 뿐** (결정 104) — `Model.form.omit/extend/merge` 는
|
|
636
|
+
없다. 검증되는 부분 폼 = `pick()`, 스키마와 무관한 입력만 애드혹
|
|
637
|
+
`{ _row: {} as T }`(검증 없음 · `agents/web.md` §3).
|
|
604
638
|
|
|
605
639
|
## 관련 결정 번호
|
|
606
640
|
|
|
@@ -615,4 +649,7 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
615
649
|
| 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
|
|
616
650
|
| 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
|
|
617
651
|
| 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
|
|
652
|
+
| 결정 104 | 스키마 파생 폼에 name·defs 탑재(실검증) · `Model.form.pick()` 검증되는 부분 폼(§8.1) |
|
|
653
|
+
| 결정 108 | 정적 default 컬럼 빈 입력 채움(§8.1) · 동적 default 는 DB 위임 |
|
|
654
|
+
| 결정 110 | `latest()`·`orderBy` 정렬 뒤 PK 타이브레이커 자동 부가(결정적 페이지네이션 · §4) |
|
|
618
655
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|