@gaonjs/cli 0.25.0 → 0.27.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/link-button-nesting.d.ts +5 -0
- package/dist/doctor/link-button-nesting.js +92 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +7 -2
- package/dist/index.js +2 -2
- package/dist/templates/auth/Dashboard.vue.tpl +10 -11
- package/dist/templates/auth/Login.vue.tpl +5 -2
- package/dist/templates/auth/Signup.vue.tpl +5 -2
- package/dist/templates/auth/dashboard.controller.ts.tpl +4 -3
- package/dist/templates/auth/registration.controller.ts.tpl +2 -2
- package/dist/templates/auth/session.controller.ts.tpl +2 -3
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/data.md.tpl +50 -2
- package/dist/templates/project/agents/frontend.md.tpl +22 -1
- package/dist/templates/project/agents/testing.md.tpl +53 -6
- package/dist/templates/project/agents/web.md.tpl +72 -2
- package/dist/templates/project/test/setup.ts.tpl +24 -0
- package/dist/templates/project/vitest.config.ts.tpl +23 -0
- package/dist/templates/ui-kit/Button.vue.tpl +25 -5
- package/package.json +4 -4
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,5 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에 Link>Button 이중 감싸기가 있는지(단위 테스트 진입점 · 주석 제외). */
|
|
3
|
+
export declare function usesLinkButtonNesting(source: string): boolean;
|
|
4
|
+
/** apps/ 의 .vue 를 훑어 Link>Button 이중 감싸기를 경고로 낸다. */
|
|
5
|
+
export declare function checkLinkButtonNesting(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · Link 로 Button 을 감싼 이중 표면 검출 (결정 113 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// `<Link href="/x"><Button>…</Button></Link>` 는 <a><button> 중첩을 만든다 —
|
|
4
|
+
// HTML 비준수(인터랙티브 요소 안에 인터랙티브 요소)이고 접근성 결함이다(첫·넷째
|
|
5
|
+
// 실사용 블로그에서 관측). 버튼 모양 링크는 한 표면으로 낸다: `<Button href="/x">`.
|
|
6
|
+
// Button 이 href 를 받으면 내부에서 gaonjs/vue 의 Link(=<a> · SPA 이동)로 렌더한다.
|
|
7
|
+
//
|
|
8
|
+
// 판정은 소스 텍스트 기반. 오탐 방지: Link 가 **직계 자식으로 Button 을** 감쌀
|
|
9
|
+
// 때만 잡는다(Link 의 `>` 바로 뒤가 `<Button`). 텍스트·다른 요소를 감싼 Link 나
|
|
10
|
+
// Button 을 안 쓰는 Link 는 건드리지 않는다.
|
|
11
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
// 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — 안티패턴을
|
|
14
|
+
// "쓰지 말라"고 설명하는 주석이 오탐을 내지 않도록.
|
|
15
|
+
function stripCommentsKeepLines(source) {
|
|
16
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
17
|
+
return source
|
|
18
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
19
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
20
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
|
|
21
|
+
}
|
|
22
|
+
// Link 여는 태그 하나에서 href(정적 또는 바인딩)를 뽑는다 — 수리 안내 문구용(없으면 null).
|
|
23
|
+
function linkHref(openTag) {
|
|
24
|
+
const staticM = openTag.match(/\shref\s*=\s*(['"])([^'"]*)\1/i);
|
|
25
|
+
if (staticM)
|
|
26
|
+
return staticM[2].trim();
|
|
27
|
+
const boundM = openTag.match(/(?::|v-bind:)href\s*=\s*(['"])([^'"]*)\1/i);
|
|
28
|
+
if (boundM)
|
|
29
|
+
return boundM[1] === '"' ? `:href="${boundM[2].trim()}"` : `:href='${boundM[2].trim()}'`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// Link 가 직계 자식으로 Button 을 감싸는 패턴(주석 제거 후 소스 대상).
|
|
33
|
+
// `<Link ...>`(자기닫힘 아님) 바로 뒤(공백만 허용)가 `<Button`.
|
|
34
|
+
const WRAP_RE = /<Link\b([^>]*)>\s*<Button\b/i;
|
|
35
|
+
/** 소스에 Link>Button 이중 감싸기가 있는지(단위 테스트 진입점 · 주석 제외). */
|
|
36
|
+
export function usesLinkButtonNesting(source) {
|
|
37
|
+
return WRAP_RE.test(stripCommentsKeepLines(source));
|
|
38
|
+
}
|
|
39
|
+
/** apps/ 의 .vue 를 훑어 Link>Button 이중 감싸기를 경고로 낸다. */
|
|
40
|
+
export async function checkLinkButtonNesting(cwd) {
|
|
41
|
+
const appsDir = join(cwd, 'apps');
|
|
42
|
+
const issues = [];
|
|
43
|
+
for (const abs of await walkVue(appsDir)) {
|
|
44
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
45
|
+
const stripped = stripCommentsKeepLines(source);
|
|
46
|
+
const rel = relative(cwd, abs);
|
|
47
|
+
for (const m of stripped.matchAll(/<Link\b([^>]*)>\s*<Button\b/gi)) {
|
|
48
|
+
const line = stripped.slice(0, m.index ?? 0).split('\n').length;
|
|
49
|
+
const href = linkHref(`<Link${m[1]}>`);
|
|
50
|
+
const hrefAttr = href === null ? 'href="/…"' : href.startsWith(':') ? href : `href="${href}"`;
|
|
51
|
+
issues.push({
|
|
52
|
+
rule: 'link-button-nesting',
|
|
53
|
+
level: 'warning',
|
|
54
|
+
file: rel,
|
|
55
|
+
line,
|
|
56
|
+
message: `Link 가 Button 을 감쌌습니다: ${rel}:${line} 의 \`<Link><Button>…</Button></Link>\` 는 ` +
|
|
57
|
+
`<a><button> 중첩(HTML 비준수·접근성 결함)입니다.\n` +
|
|
58
|
+
`→ 버튼 모양 링크는 한 표면 \`<Button ${hrefAttr}>…</Button>\` 로 내세요 — Button 이 ` +
|
|
59
|
+
`href 를 받으면 내부에서 SPA 이동 링크로 렌더합니다(결정 113). 외부 URL 이면 ` +
|
|
60
|
+
`\`<Button ${hrefAttr} external target="_blank">\`. Link 로 감싸지 마세요.`,
|
|
61
|
+
detail: { file: rel, line, href },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { rule: 'link-button-nesting', issues };
|
|
66
|
+
}
|
|
67
|
+
/** apps/ 하위 .vue(선언·테스트 제외) 절대경로. */
|
|
68
|
+
async function walkVue(dir) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const walk = async (d) => {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const abs = join(d, e.name);
|
|
80
|
+
if (e.isDirectory()) {
|
|
81
|
+
if (e.name === 'node_modules' || e.name === '.gaon')
|
|
82
|
+
continue;
|
|
83
|
+
await walk(abs);
|
|
84
|
+
}
|
|
85
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
86
|
+
out.push(abs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
await walk(dir);
|
|
91
|
+
return out.sort();
|
|
92
|
+
}
|
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' | 'page-layout-breakpoint';
|
|
1
|
+
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
package/dist/doctor.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './
|
|
|
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
22
|
export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
|
|
23
|
+
export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
|
|
23
24
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
24
25
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
25
26
|
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
|
+
* 23 검사를 조립한다:
|
|
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 규칙)
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
|
|
25
25
|
* 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
|
|
26
26
|
* 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
|
|
27
|
+
* 23) link-button-nesting (결정 113 · Link 로 Button 감싸기 = <a><button> 중첩 경고)
|
|
27
28
|
*
|
|
28
29
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
29
30
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -58,6 +59,7 @@ import { checkInternalAnchor } from './doctor/internal-anchor.js';
|
|
|
58
59
|
import { checkPagePropsDestructure } from './doctor/pageprops-destructure.js';
|
|
59
60
|
import { checkAsyncOffload } from './doctor/async-offload.js';
|
|
60
61
|
import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
|
|
62
|
+
import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
|
|
61
63
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
62
64
|
import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
63
65
|
import { makeResult, } from './doctor/types.js';
|
|
@@ -81,10 +83,11 @@ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './
|
|
|
81
83
|
export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
|
|
82
84
|
export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
|
|
83
85
|
export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
|
|
86
|
+
export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
|
|
84
87
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
85
88
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
86
89
|
/**
|
|
87
|
-
* 실행할 검사 이름. 지정 없음(undefined) =
|
|
90
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 23개 모두.
|
|
88
91
|
*/
|
|
89
92
|
const ALL_RULES = [
|
|
90
93
|
'response-mixing',
|
|
@@ -109,6 +112,7 @@ const ALL_RULES = [
|
|
|
109
112
|
'pageprops-destructure',
|
|
110
113
|
'async-offload',
|
|
111
114
|
'page-layout-breakpoint',
|
|
115
|
+
'link-button-nesting',
|
|
112
116
|
];
|
|
113
117
|
const CHECKERS = {
|
|
114
118
|
'response-mixing': checkResponseMixing,
|
|
@@ -133,6 +137,7 @@ const CHECKERS = {
|
|
|
133
137
|
'pageprops-destructure': checkPagePropsDestructure,
|
|
134
138
|
'async-offload': checkAsyncOffload,
|
|
135
139
|
'page-layout-breakpoint': checkPageLayoutBreakpoint,
|
|
140
|
+
'link-button-nesting': checkLinkButtonNesting,
|
|
136
141
|
};
|
|
137
142
|
/**
|
|
138
143
|
* 규칙을 순서대로 실행해 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 정적 검사 (23 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩)",
|
|
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,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { useShared, router } from 'gaonjs/vue'
|
|
3
3
|
import Card from '@shared/components/ui/Card.vue'
|
|
4
4
|
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
5
|
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
@@ -8,25 +8,24 @@ import CardContent from '@shared/components/ui/CardContent.vue'
|
|
|
8
8
|
import CardFooter from '@shared/components/ui/CardFooter.vue'
|
|
9
9
|
import Button from '@shared/components/ui/Button.vue'
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// 사용자·csrf 는 자동 주입 공유 prop 이다(결정 116) — 컨트롤러가 넘기지 않고
|
|
12
|
+
// useShared() 로 읽는다. currentUser 는 직렬화됨(passwordDigest 없음 · §4.2).
|
|
13
|
+
// 이 페이지는 requireAuth 로 보호되므로 런타임엔 항상 로그인 상태(타입은 nullable).
|
|
14
|
+
const shared = useShared()
|
|
14
15
|
|
|
15
16
|
// 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
|
|
16
|
-
// HTML <form> 은 DELETE 를
|
|
17
|
-
// 보낸다(결정 64) — `?_method=DELETE` 우회는 서버가 해석하지 않아 POST /session
|
|
18
|
-
// (= 로그인 create) 으로 잘못 라우팅됐다.
|
|
17
|
+
// HTML <form> 은 DELETE 를 못 보내므로 Inertia 라우터로 실제 메서드를 보낸다(결정 64).
|
|
19
18
|
function logout(): void {
|
|
20
|
-
router.delete('/session', { headers: { 'x-csrf-token':
|
|
19
|
+
router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
|
|
21
20
|
}
|
|
22
21
|
</script>
|
|
23
22
|
|
|
24
23
|
<template>
|
|
25
24
|
<div class="mx-auto max-w-2xl px-4 py-10">
|
|
26
|
-
<Card>
|
|
25
|
+
<Card v-if="shared.currentUser">
|
|
27
26
|
<CardHeader>
|
|
28
|
-
<CardTitle>환영합니다, {{
|
|
29
|
-
<CardDescription>{{
|
|
27
|
+
<CardTitle>환영합니다, {{ shared.currentUser.name }}님</CardTitle>
|
|
28
|
+
<CardDescription>{{ shared.currentUser.email }}</CardDescription>
|
|
30
29
|
</CardHeader>
|
|
31
30
|
<CardContent>
|
|
32
31
|
<p class="text-sm text-muted-foreground">보호된 페이지입니다 — this.requireAuth() 로 지킵니다.</p>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
2
|
+
import { pageProps, useForm, useShared, Link } from 'gaonjs/vue'
|
|
3
3
|
import Card from '@shared/components/ui/Card.vue'
|
|
4
4
|
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
5
|
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
@@ -17,9 +17,12 @@ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
|
|
|
17
17
|
// 실패로 서버가 같은 페이지를 다시 render 하면 props.error 가 즉시 갱신된다.
|
|
18
18
|
const props = pageProps<'{{APP_NAME}}:session#new'>()
|
|
19
19
|
|
|
20
|
+
// csrf 는 자동 주입 공유 prop 이다(결정 116) — useShared() 로 읽는다.
|
|
21
|
+
const shared = useShared()
|
|
22
|
+
|
|
20
23
|
// 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
|
|
21
24
|
// 서버는 redirect(Inertia 응답)로 답하고, 실패 시 같은 페이지를 다시 render 한다.
|
|
22
|
-
const form = useForm({ email: '', password: '', _csrf:
|
|
25
|
+
const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
23
26
|
</script>
|
|
24
27
|
|
|
25
28
|
<template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
2
|
+
import { pageProps, useForm, useShared, Link } from 'gaonjs/vue'
|
|
3
3
|
import Card from '@shared/components/ui/Card.vue'
|
|
4
4
|
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
5
|
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
@@ -15,8 +15,11 @@ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
|
|
|
15
15
|
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
16
16
|
const props = pageProps<'{{APP_NAME}}:registration#new'>()
|
|
17
17
|
|
|
18
|
+
// csrf 는 자동 주입 공유 prop 이다(결정 116) — useShared() 로 읽는다.
|
|
19
|
+
const shared = useShared()
|
|
20
|
+
|
|
18
21
|
// 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
|
|
19
|
-
const form = useForm({ name: '', email: '', password: '', _csrf:
|
|
22
|
+
const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
|
|
20
23
|
</script>
|
|
21
24
|
|
|
22
25
|
<template>
|
|
@@ -4,8 +4,9 @@ import { controller } from 'gaonjs/web'
|
|
|
4
4
|
export default controller({
|
|
5
5
|
// GET /dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
|
|
6
6
|
async show() {
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
|
|
7
|
+
// 보호만 하고 페이지 데이터는 넘기지 않는다 — 사용자·csrf 는 자동 주입되고
|
|
8
|
+
// (결정 116), 페이지는 useShared() 로 읽는다. requireAuth 로 게이트만 건다.
|
|
9
|
+
this.requireAuth()
|
|
10
|
+
return this.render('Dashboard', {})
|
|
10
11
|
},
|
|
11
12
|
})
|
|
@@ -3,9 +3,9 @@ import { controller, hashPassword } from 'gaonjs/web'
|
|
|
3
3
|
import { User } from '../../../domain/models/User.js'
|
|
4
4
|
|
|
5
5
|
export default controller({
|
|
6
|
-
// GET /registration/new — 회원가입
|
|
6
|
+
// GET /registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
|
|
7
7
|
async new() {
|
|
8
|
-
return this.render('Auth/Signup', { error: null as string | null
|
|
8
|
+
return this.render('Auth/Signup', { error: null as string | null })
|
|
9
9
|
},
|
|
10
10
|
// POST /registration — 회원가입
|
|
11
11
|
async create() {
|
|
@@ -3,9 +3,9 @@ import { controller, verifyPassword } from 'gaonjs/web'
|
|
|
3
3
|
import { User } from '../../../domain/models/User.js'
|
|
4
4
|
|
|
5
5
|
export default controller({
|
|
6
|
-
// GET /session/new — 로그인
|
|
6
|
+
// GET /session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
|
|
7
7
|
async new() {
|
|
8
|
-
return this.render('Auth/Login', { error: null as string | null
|
|
8
|
+
return this.render('Auth/Login', { error: null as string | null })
|
|
9
9
|
},
|
|
10
10
|
// POST /session — 로그인
|
|
11
11
|
async create() {
|
|
@@ -18,7 +18,6 @@ export default controller({
|
|
|
18
18
|
}
|
|
19
19
|
return this.render('Auth/Login', {
|
|
20
20
|
error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
|
|
21
|
-
csrf: this.csrfToken(),
|
|
22
21
|
})
|
|
23
22
|
},
|
|
24
23
|
// DELETE /session — 로그아웃
|
|
@@ -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` 검사 23종
|
|
108
108
|
|
|
109
109
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
110
110
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -128,6 +128,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
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
130
|
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
131
|
+
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
131
132
|
|
|
132
133
|
## 3. 로직 배치 One Way 판단표
|
|
133
134
|
|
|
@@ -188,7 +189,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
188
189
|
```bash
|
|
189
190
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
190
191
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
191
|
-
gaon doctor # 정적 검사
|
|
192
|
+
gaon doctor # 정적 검사 23종 (§2.2)
|
|
192
193
|
```
|
|
193
194
|
|
|
194
195
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
gaon check # .gaon 재생성 후 타입 검사 (CI 정합)
|
|
88
|
-
gaon doctor # 정적 검사
|
|
88
|
+
gaon doctor # 정적 검사 23종 (상세 AGENTS §2.2)
|
|
89
89
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -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` |
|
|
@@ -195,6 +195,8 @@ export const posts = table('posts', {
|
|
|
195
195
|
| `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
|
|
196
196
|
| `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
|
|
197
197
|
| `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
|
|
198
|
+
| `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy 있으면 throw)은 updateAll 과 동일 |
|
|
199
|
+
| `decrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 감소**(결정 115) — `SET col = col - by`. incrementAll 의 대칭 |
|
|
198
200
|
|
|
199
201
|
**집계·조인 그룹** (`Chain` · M2E · 결정 34):
|
|
200
202
|
|
|
@@ -261,6 +263,26 @@ export const posts = table('posts', {
|
|
|
261
263
|
(§6 `Serialized<T>` 는 함수 값을 떨군다).
|
|
262
264
|
- 사용자 메서드 (§8 `methods`) — `this` = Rec 으로 바인딩.
|
|
263
265
|
|
|
266
|
+
**원자 프리미티브 (결정 115) — 카운터·플래그는 read-modify-write 하지 않는다:**
|
|
267
|
+
|
|
268
|
+
값을 읽어 계산해 다시 쓰면(`rec.update({ views: rec.views + 1 })`) 동시 요청에서
|
|
269
|
+
증가가 유실된다(경쟁 조건). SQL 표현식을 한 문장으로 실행하는 원자 메서드를 쓴다:
|
|
270
|
+
|
|
271
|
+
- `rec.increment(field, by?=1)` / `rec.decrement(field, by?=1)` — `SET col = col ± by`.
|
|
272
|
+
**수치 컬럼만**(number·bigint). 갱신 후 값을 반환하고 `rec` 필드도 그 값으로 맞춘다.
|
|
273
|
+
- `rec.touch(field?='updatedAt')` — 값을 읽지 않고 현재 시각으로 원자 갱신. **날짜 컬럼만** · `void`.
|
|
274
|
+
- `rec.toggle(field)` — `SET col = NOT col`. **boolean 컬럼만** · 갱신 후 boolean 반환.
|
|
275
|
+
- 컬렉션 버전은 체인 `incrementAll`·`decrementAll`(§4 표 · 영향 행 수 반환).
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
// domain/models/Post.ts — 조회수 카운터(원자 · 경쟁 조건 없음)
|
|
279
|
+
methods: {
|
|
280
|
+
async recordView() {
|
|
281
|
+
return await this.increment('viewCount') // NOT: this.update({ viewCount: this.viewCount + 1 })
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
264
286
|
**체인 상태 전이 주의** (`model.ts:112-146`):
|
|
265
287
|
|
|
266
288
|
- `select()`·`include()` 이후에도 빌더 메서드(`where`·`orWhere`·
|
|
@@ -395,6 +417,24 @@ export const Post = model(posts, {
|
|
|
395
417
|
})
|
|
396
418
|
```
|
|
397
419
|
|
|
420
|
+
- **여러 모델을 조합하는 읽기 질의도 이름을 붙인다 (§5.3 읽기 규칙 · 결정 114).**
|
|
421
|
+
검색·태그 필터처럼 여러 모델/서브쿼리를 엮는 읽기는 컨트롤러에 인라인 조립하지
|
|
422
|
+
않는다 — 주 모델이 분명하면 **모델 정적 메서드(스코프)**, 대등한 조합이면
|
|
423
|
+
`domain/services/` 로 이름을 붙인다. 컨트롤러에 허용되는 쿼리는 **스코프 체인
|
|
424
|
+
한 줄**까지다(`await Post.published().latest().limit(20).all()`).
|
|
425
|
+
```ts
|
|
426
|
+
// ❌ 컨트롤러 인라인 조립 (검색 교집합을 컨트롤러가 조립)
|
|
427
|
+
// const ids = await Post.where('title','ilike',p).orWhere('body','ilike',p).pluck('id')
|
|
428
|
+
// const rows = await Post.published().whereIn('id', ids).latest().all()
|
|
429
|
+
// ✅ 이름 붙인 스코프 — 컨트롤러는 한 줄
|
|
430
|
+
scopes: {
|
|
431
|
+
searchPublished: (q, term: string) =>
|
|
432
|
+
q.where('published', '=', true)
|
|
433
|
+
.where('title', 'ilike', `%${term}%`).orWhere('body', 'ilike', `%${term}%`),
|
|
434
|
+
}
|
|
435
|
+
// 컨트롤러: const rows = await Post.searchPublished(term).latest().all()
|
|
436
|
+
```
|
|
437
|
+
|
|
398
438
|
### 8.1 스키마 파생 폼 — `Model.form` · `Model.form.pick()` (결정 104)
|
|
399
439
|
|
|
400
440
|
모든 모델은 `Model.form` 으로 **스키마 파생 폼**을 노출한다 — 컨트롤러의
|
|
@@ -420,6 +460,10 @@ async create() {
|
|
|
420
460
|
- `pick()` 결과도 폼이다 — 부분집합으로 다시 `pick()` 할 수 있다.
|
|
421
461
|
- 스키마에 없는 컬럼을 지정하면 즉시 throw(수리 안내 포함) · 빈
|
|
422
462
|
`pick()` 도 throw · 중복 지정은 조용히 하나로 합친다.
|
|
463
|
+
- **정적 default 컬럼은 빈 입력이면 그 default 로 채워진다**(결정 108) —
|
|
464
|
+
`t.string().default('N/A')` 같은 컬럼을 pick 해 빈 값('')·미전송으로 받으면
|
|
465
|
+
런타임이 default 를 채운다(폼 타입이 required 인데 undefined 로 새지 않는다).
|
|
466
|
+
동적 default(`now()`·`gen_random_uuid()`·bigserial)는 채우지 않고 DB 가 채운다.
|
|
423
467
|
- `omit`·`extend`·`merge` 는 **없다** — 폼 변형은 `pick()` 하나가 The One Way.
|
|
424
468
|
|
|
425
469
|
### 9. 서비스 (`service()`) — 트랜잭션 작업 흐름 (정본 §5.3 · `packages/data/src/service.ts`)
|
|
@@ -646,4 +690,8 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
646
690
|
| 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
|
|
647
691
|
| 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
|
|
648
692
|
| 결정 104 | 스키마 파생 폼에 name·defs 탑재(실검증) · `Model.form.pick()` 검증되는 부분 폼(§8.1) |
|
|
693
|
+
| 결정 108 | 정적 default 컬럼 빈 입력 채움(§8.1) · 동적 default 는 DB 위임 |
|
|
694
|
+
| 결정 110 | `latest()`·`orderBy` 정렬 뒤 PK 타이브레이커 자동 부가(결정적 페이지네이션 · §4) |
|
|
695
|
+
| 결정 114 | 여러 모델 조합 읽기 = 이름 붙임(모델 정적 메서드/`domain/services/`) · 컨트롤러는 스코프 체인 한 줄까지(§8 · §5.3) |
|
|
696
|
+
| 결정 115 | 원자 프리미티브 increment·decrement·touch·toggle(Rec) + incrementAll·decrementAll(Chain) · read-modify-write 금지(§4) |
|
|
649
697
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -41,6 +41,16 @@ const props = pageProps<'web:posts#index'>()
|
|
|
41
41
|
- **라우트 키** = `<app>:<controller>#<action>` (앱 폴더명 · 컨트롤러 파일명
|
|
42
42
|
stem · 소문자 복수 · 결정 55). `apps/web/controllers/posts.ts` 의 `index`
|
|
43
43
|
액션 → `'web:posts#index'`.
|
|
44
|
+
- **공유 prop(currentUser·csrf·flash) = `useShared()`(결정 116) — 라우트 키 없이 읽는다.**
|
|
45
|
+
이 셋은 디스패처가 **모든** 렌더에 자동 주입하므로 컨트롤러가 넘기지 않는다(넘기면
|
|
46
|
+
컴파일 에러 · 결정 117). 레이아웃·컴포넌트에서 라우트를 모른 채 읽을 때 특히 유용하다:
|
|
47
|
+
```vue
|
|
48
|
+
import { useShared } from 'gaonjs/vue'
|
|
49
|
+
const shared = useShared() // { currentUser, csrf, flash } · 반응형
|
|
50
|
+
// <template> 에서 shared.currentUser?.name · shared.csrf · shared.flash.success
|
|
51
|
+
```
|
|
52
|
+
`pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
|
|
53
|
+
`useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
|
|
44
54
|
- **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
|
|
45
55
|
로 import 하지 않는다.
|
|
46
56
|
- **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
|
|
@@ -252,10 +262,18 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
252
262
|
- **폼은 UI 킷 Form + gaonjs `useForm`(결정 64)** — `Form` 은 얇은 `<form>` 래퍼로
|
|
253
263
|
`@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
|
|
254
264
|
않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
|
|
255
|
-
오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을
|
|
265
|
+
오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을 표시한다 —
|
|
266
|
+
서버 스키마 검증 실패는 `form.errors.<field>` 로 **자동 반영**된다(결정 109 ·
|
|
267
|
+
컨트롤러가 손으로 다시 렌더하지 않는다 · `agents/web.md` §4.1).
|
|
256
268
|
- **`class` 는 폴스루로 병합** — 단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
|
|
257
269
|
넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
|
|
258
270
|
자동 해소를 하지 않는다 — 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
|
|
271
|
+
- **버튼 모양 링크 = `<Button href>`(결정 113) — `Link` 로 `Button` 을 감싸지 않는다.**
|
|
272
|
+
`<Link href="/x"><Button>…</Button></Link>` 는 `<a><button>` 중첩(HTML 비준수·접근성
|
|
273
|
+
결함)이다. `Button` 에 `href` 를 주면 내부에서 SPA 이동 링크(`Link`=`<a>`)로 렌더한다:
|
|
274
|
+
`<Button href="/posts/new">새 글</Button>`. 외부 URL 은 `<Button href="https://…" external
|
|
275
|
+
target="_blank">`. 순수 버튼은 `href` 없이 `<Button @click="…">`. doctor **link-button-nesting**
|
|
276
|
+
이 Link>Button 중첩을 경고한다.
|
|
259
277
|
- **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
|
|
260
278
|
`text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
|
|
261
279
|
`:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
|
|
@@ -364,4 +382,7 @@ async function runSearch(q: string) {
|
|
|
364
382
|
| 결정 105 | UI 킷 shared 이전(`shared/components/ui` 프로젝트당 한 벌 · `@shared` alias · 결정 75 개정) |
|
|
365
383
|
| 결정 106 | 최소 4블록(PageShell·PageHeader·EmptyState·Pagination · 성격 중립) |
|
|
366
384
|
| 결정 107 | 반응형은 킷 책임(페이지 레이아웃 브레이크포인트 지양 · doctor page-layout-breakpoint 안내 경고 · 터치 44px·폰트 최소 크기 토큰) |
|
|
385
|
+
| 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
|
|
386
|
+
| 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
|
|
387
|
+
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
|
|
367
388
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
테스트를 통과해 운영에서 터지는 구멍이었다.
|
|
17
17
|
|
|
18
18
|
- 테스트 전에 compose 로 DB · NATS 를 띄우고, **테스트 전용
|
|
19
|
-
데이터베이스**(
|
|
20
|
-
|
|
21
|
-
- `gaon test`
|
|
19
|
+
데이터베이스**(truncate 격리 · 결정 111) + **테스트 전용 스트림
|
|
20
|
+
프리픽스**를 쓴다.
|
|
21
|
+
- `gaon test` 가 테스트 전용 데이터베이스(`<db>_test`)를 **자동 준비**한다
|
|
22
|
+
— 없으면 만들고(CREATE DATABASE) 마이그레이션까지 적용한 뒤 vitest 를
|
|
23
|
+
돌린다. 스캐폴드 `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`)
|
|
24
|
+
매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
|
|
22
25
|
- SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
|
|
23
26
|
아니다.
|
|
24
27
|
|
|
@@ -78,11 +81,54 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
|
78
81
|
- `configureJobs` 는 헬퍼가 대신 해 준다 — 테스트가 부팅 코드를 흉내낼
|
|
79
82
|
필요가 없다.
|
|
80
83
|
|
|
84
|
+
### 5. DB 테스트 격리 — `gaon test` + `test/setup.ts` (결정 111)
|
|
85
|
+
|
|
86
|
+
DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test` 와 스캐폴드
|
|
87
|
+
`test/setup.ts` 가 The One Way 를 제공한다:
|
|
88
|
+
|
|
89
|
+
- `gaon test` 가 테스트 전용 DB(`<db>_test`)를 만들고 마이그레이션한다.
|
|
90
|
+
- `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`) 매 테스트 뒤
|
|
91
|
+
전 테이블을 비운다(`truncateAll`) — 새 테스트는 항상 빈 DB 에서 시작한다.
|
|
92
|
+
|
|
93
|
+
스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
97
|
+
import { connectTestDatabase, truncateAll, type TestDbHandle } from 'gaonjs/testing'
|
|
98
|
+
|
|
99
|
+
let handle: TestDbHandle
|
|
100
|
+
beforeAll(async () => { handle = await connectTestDatabase() })
|
|
101
|
+
afterEach(async () => { await truncateAll() })
|
|
102
|
+
afterAll(async () => { await handle?.close() })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
그러면 테스트는 격리 코드 없이 모델·서비스를 그대로 부른다:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// test/posts.integration.test.ts
|
|
109
|
+
import { describe, it, expect } from 'vitest'
|
|
110
|
+
import { Post } from '../domain/models/Post.js'
|
|
111
|
+
|
|
112
|
+
describe('Post', () => {
|
|
113
|
+
it('생성·조회', async () => {
|
|
114
|
+
await Post.create({ title: '첫 글', body: '...' })
|
|
115
|
+
expect(await Post.count()).toBe(1n) // 다음 테스트 전 truncateAll 로 0 으로 리셋
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**왜 truncate 인가(트랜잭션 롤백 아님) · 결정 111**: `service()` 는 실제
|
|
121
|
+
BEGIN/COMMIT 을 여는 대상이라, 테스트를 바깥 트랜잭션으로 감싸면 service
|
|
122
|
+
안의 COMMIT 이 그 바깥 트랜잭션을 커밋해 롤백 격리가 조용히 깨진다(실측).
|
|
123
|
+
그래서 격리는 service 가 실제로 커밋하는 운영 경로를 그대로 두고 매 테스트
|
|
124
|
+
뒤 truncate 로 비운다 — service 든 아니든 항상 안전하다.
|
|
125
|
+
|
|
81
126
|
## 정본 예시
|
|
82
127
|
|
|
83
|
-
위 §4 의 `welcomeMail.integration.test.ts` 가 잡 검증의 정본
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
위 §4 의 `welcomeMail.integration.test.ts` 가 잡 검증의 정본 예시이고,
|
|
129
|
+
§5 의 `test/setup.ts` + 모델 테스트가 DB 격리의 정본 예시다. 발행 도착만
|
|
130
|
+
확인하고 싶으면 raw JetStream 구독(§3)도 정합이지만, 기본 경로는
|
|
131
|
+
`expectJobProcessed` 하나다 (The One Way).
|
|
86
132
|
|
|
87
133
|
## 알려진 함정
|
|
88
134
|
|
|
@@ -99,5 +145,6 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
|
99
145
|
| 결정 | 내용 |
|
|
100
146
|
|---|---|
|
|
101
147
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
|
|
148
|
+
| 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
|
|
102
149
|
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
103
150
|
| 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
|
|
@@ -167,17 +167,82 @@ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드
|
|
|
167
167
|
```ts
|
|
168
168
|
// 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
|
|
169
169
|
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
170
|
+
// csrf 는 자동 주입 공유 prop — useShared() 로 읽는다(결정 116).
|
|
170
171
|
const props = pageProps<'web:session#new'>()
|
|
171
|
-
const
|
|
172
|
+
const shared = useShared()
|
|
173
|
+
const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
172
174
|
// <form @submit.prevent="form.post('/session')"> · 실패 시 {{ props.error }} 가 반응형으로 갱신
|
|
173
175
|
|
|
174
176
|
// HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다.
|
|
175
|
-
router.delete('/session', { headers: { 'x-csrf-token':
|
|
177
|
+
router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
|
|
176
178
|
```
|
|
177
179
|
|
|
178
180
|
`?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
|
|
179
181
|
엉뚱한 액션(create)에 도달한다.
|
|
180
182
|
|
|
183
|
+
### 4.1 폼 검증 에러 — 스키마 검증만으로 `useForm.errors` 자동 반영 (결정 109)
|
|
184
|
+
|
|
185
|
+
Inertia 폼(`useForm(...).post()`)의 검증 실패는 컨트롤러가 손으로 다시
|
|
186
|
+
렌더하지 않는다 — `this.params(Model.form)` 이 던진 검증 실패를 프레임웍이
|
|
187
|
+
**303 back + 세션 플래시 errors** 로 처리하고, 클라이언트 `useForm` 이 다음
|
|
188
|
+
방문의 `errors` 로 `form.errors.<필드>` 를 **자동으로 채운다**. 입력값은
|
|
189
|
+
`useForm` 이 그대로 보존한다(재제출 방지). 컨트롤러는 성공 경로만 쓴다:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// 컨트롤러 — 검증 실패 분기를 손으로 쓰지 않는다(결정 109).
|
|
193
|
+
async create() {
|
|
194
|
+
const data = this.params(Post.form.pick('title', 'body')) // 실패 시 프레임웍이 303 back
|
|
195
|
+
await Post.create(data)
|
|
196
|
+
return this.redirect('/posts')
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```vue
|
|
201
|
+
<!-- 페이지 — form.errors.<필드> 는 서버 검증 실패 시 자동으로 채워진다. -->
|
|
202
|
+
<input v-model="form.title" />
|
|
203
|
+
<p v-if="form.errors.title">{{ form.errors.title }}</p>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
|
|
207
|
+
|
|
208
|
+
### 4.2 공유 prop 자동 주입 — currentUser·csrf·flash (결정 116·117)
|
|
209
|
+
|
|
210
|
+
디스패처가 **모든** Inertia 렌더에 공유 prop 3종을 자동 주입한다 — 컨트롤러가
|
|
211
|
+
손으로 넘기지 않는다:
|
|
212
|
+
|
|
213
|
+
- `currentUser` — 현재 로그인 사용자(직렬화 · 미로그인 `null`). 인증 augment 가 채운다.
|
|
214
|
+
- `csrf` — 요청별 CSRF 토큰.
|
|
215
|
+
- `flash` — 세션 플래시 백. `this.flash(key, value)` 로 심고(redirect-then-render),
|
|
216
|
+
다음 렌더의 `flash.<key>` 로 한 번 읽히면 소멸한다.
|
|
217
|
+
|
|
218
|
+
페이지·레이아웃은 **`useShared()`(gaonjs/vue)** 로 라우트 키 없이 읽는다(결정 116 ·
|
|
219
|
+
`agents/frontend.md` §1). `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽힌다.
|
|
220
|
+
|
|
221
|
+
- **예약 키는 render props 에 넣으면 컴파일 에러(결정 117).** `currentUser`·`csrf`·`flash`
|
|
222
|
+
는 자동 주입되므로 `this.render('p', { currentUser })` 는 타입 에러(+런타임 방어)다 —
|
|
223
|
+
그 이름을 페이지 데이터로 쓰지 말고, 필요하면 다른 이름을 쓴다.
|
|
224
|
+
```ts
|
|
225
|
+
// ❌ 자동 주입값을 손으로 넘기지 않는다(컴파일 에러)
|
|
226
|
+
// return this.render('Dashboard', { user, csrf: this.csrfToken() })
|
|
227
|
+
// ✅ 보호만 하고 페이지 데이터는 넘기지 않는다 — 페이지가 useShared() 로 읽는다
|
|
228
|
+
async show() {
|
|
229
|
+
this.requireAuth()
|
|
230
|
+
return this.render('Dashboard', {})
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 4.3 읽기 조합은 컨트롤러 인라인 조립하지 않는다 (§5.3 · 결정 114)
|
|
235
|
+
|
|
236
|
+
컨트롤러 액션에 허용되는 쿼리는 **스코프 체인 한 줄**까지다. 검색·태그 필터처럼
|
|
237
|
+
여러 모델/서브쿼리를 엮는 읽기는 이름을 붙인다 — 주 모델이 분명하면 **모델 정적
|
|
238
|
+
메서드(스코프)**, 대등한 조합이면 `domain/services/`(`agents/data.md` §8). 컨트롤러가
|
|
239
|
+
`pluck`·교집합·사이드바 질의를 인라인 조립하기 시작하면 서비스/스코프로 옮긴다.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
|
|
243
|
+
// ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
|
|
244
|
+
```
|
|
245
|
+
|
|
181
246
|
### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
|
|
182
247
|
|
|
183
248
|
회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
|
|
@@ -345,4 +410,9 @@ export default controller({
|
|
|
345
410
|
| 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
|
|
346
411
|
| 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
|
|
347
412
|
| 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
|
|
413
|
+
| 결정 108 | 정적 default 컬럼 빈 입력 채움(coerceParams) · 동적 default 는 DB 위임 |
|
|
414
|
+
| 결정 109 | Inertia 폼 검증 실패 = 303 back + 플래시 errors → `useForm.errors` 자동(§4.1) · JSON/API 는 422 유지 |
|
|
415
|
+
| 결정 114 | 여러 모델 조합 읽기는 이름 붙임(정적 메서드/서비스) · 컨트롤러는 스코프 체인 한 줄까지(§4.3 · `agents/data.md` §8) |
|
|
416
|
+
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `this.flash(k,v)` · 페이지는 `useShared()`(§4.2) |
|
|
417
|
+
| 결정 117 | render props 에 예약 공유 키 = 컴파일 에러 + 런타임 방어(자동 주입값 조용한 덮어쓰기 금지 · §4.2) |
|
|
348
418
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// test/setup.ts — 테스트 격리 부트스트랩 (§9 실 인프라 · 결정 111).
|
|
2
|
+
//
|
|
3
|
+
// `gaon test` 가 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤 vitest 를
|
|
4
|
+
// 돌린다. 이 파일이 그 DB 에 붙고(connectTestDatabase), 매 테스트 뒤 전 테이블을
|
|
5
|
+
// 비운다(truncateAll). 트랜잭션 롤백이 아니라 truncate 인 이유: service() 는 실제
|
|
6
|
+
// COMMIT 을 해서 바깥 트랜잭션으로 되돌릴 수 없다(결정 111 · agents/testing.md).
|
|
7
|
+
//
|
|
8
|
+
// 목업·인메모리 금지(§9) — 실 DB 로만 검증한다.
|
|
9
|
+
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
10
|
+
import { connectTestDatabase, truncateAll, type TestDbHandle } from 'gaonjs/testing'
|
|
11
|
+
|
|
12
|
+
let handle: TestDbHandle
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
handle = await connectTestDatabase()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await truncateAll()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await handle?.close()
|
|
24
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// vitest.config.ts — 테스트 러너 설정 (§9 실 인프라 · 결정 111).
|
|
2
|
+
//
|
|
3
|
+
// `gaon test`(= vitest)가 이 설정으로 돈다. 프론트 빌드는 vite.config.ts 가,
|
|
4
|
+
// 테스트는 이 파일이 담당한다(vitest 는 vitest.config 를 우선한다).
|
|
5
|
+
//
|
|
6
|
+
// 격리: `gaon test` 가 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤,
|
|
7
|
+
// test/setup.ts 가 그 DB 에 붙고 매 테스트 뒤 전 테이블을 비운다(truncate).
|
|
8
|
+
// 실 DB 하나를 공유하므로 파일 병렬을 끈다 — 병렬이면 서로의 데이터를 지운다.
|
|
9
|
+
import { defineConfig } from 'vitest/config'
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
test: {
|
|
13
|
+
setupFiles: ['./test/setup.ts'],
|
|
14
|
+
include: [
|
|
15
|
+
'test/**/*.test.ts',
|
|
16
|
+
'domain/**/*.test.ts',
|
|
17
|
+
'apps/**/*.test.ts',
|
|
18
|
+
'shared/**/*.test.ts',
|
|
19
|
+
],
|
|
20
|
+
// 실 DB 를 공유하는 통합 테스트 격리 — 파일 병렬 금지(§9 · 결정 111).
|
|
21
|
+
fileParallelism: false,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// UI 킷 · Button (결정 75). variant/size 로 모양을 고르고, 나머지
|
|
3
|
-
// 자연스럽게
|
|
2
|
+
// UI 킷 · Button (결정 75 · 결정 113). variant/size 로 모양을 고르고, 나머지 속성·
|
|
3
|
+
// 클래스는 자연스럽게 렌더 요소로 흘러간다(Vue 폴스루). 부모가 class 를 주면 뒤에 병합.
|
|
4
|
+
//
|
|
5
|
+
// 결정 113: 버튼 모양 링크는 `<Button href="/x">` 한 표면으로 낸다. `<Link>` 로
|
|
6
|
+
// `<Button>` 을 감싸면 <a><button> 중첩(HTML 비준수·접근성 결함)이 된다 — 대신
|
|
7
|
+
// href 를 주면 이 컴포넌트가 내부에서 알맞은 요소를 고른다:
|
|
8
|
+
// · href 있음(내부) → gaonjs/vue 의 <Link>(=<a> · SPA 이동)
|
|
9
|
+
// · href 있음 + external → 일반 <a>(target 지정 가능 · 외부 URL)
|
|
10
|
+
// · href 없음 → <button>
|
|
4
11
|
import { computed } from 'vue'
|
|
12
|
+
import { Link } from 'gaonjs/vue'
|
|
5
13
|
import { cn } from '../../lib/utils.js'
|
|
6
14
|
|
|
7
15
|
type Variant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
|
|
8
16
|
type Size = 'default' | 'sm' | 'lg' | 'icon'
|
|
9
17
|
|
|
10
18
|
const props = withDefaults(
|
|
11
|
-
defineProps<{
|
|
19
|
+
defineProps<{
|
|
20
|
+
variant?: Variant
|
|
21
|
+
size?: Size
|
|
22
|
+
type?: 'button' | 'submit' | 'reset'
|
|
23
|
+
/** 주면 버튼 모양 링크가 된다(내부 이동은 SPA · 결정 113). Link 로 감싸지 말 것. */
|
|
24
|
+
href?: string
|
|
25
|
+
/** 외부 URL 이면 true — 일반 <a> 로 렌더(SPA 이동 아님). target 과 함께 쓴다. */
|
|
26
|
+
external?: boolean
|
|
27
|
+
/** external 링크의 target(예: '_blank'). */
|
|
28
|
+
target?: string
|
|
29
|
+
}>(),
|
|
12
30
|
{ variant: 'default', size: 'default', type: 'button' },
|
|
13
31
|
)
|
|
14
32
|
|
|
@@ -28,12 +46,14 @@ const SIZES: Record<Size, string> = {
|
|
|
28
46
|
}
|
|
29
47
|
const BASE =
|
|
30
48
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ' +
|
|
31
|
-
'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 ' +
|
|
49
|
+
'no-underline ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 ' +
|
|
32
50
|
'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
|
|
33
51
|
|
|
34
52
|
const classes = computed(() => cn(BASE, VARIANTS[props.variant], SIZES[props.size]))
|
|
35
53
|
</script>
|
|
36
54
|
|
|
37
55
|
<template>
|
|
38
|
-
<
|
|
56
|
+
<a v-if="href && external" :href="href" :target="target" :class="classes"><slot /></a>
|
|
57
|
+
<Link v-else-if="href" :href="href" :class="classes"><slot /></Link>
|
|
58
|
+
<button v-else :type="type" :class="classes"><slot /></button>
|
|
39
59
|
</template>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/
|
|
31
|
-
"@gaonjs/config": "0.5.4",
|
|
30
|
+
"@gaonjs/config": "0.7.0",
|
|
32
31
|
"@gaonjs/core": "0.2.1",
|
|
33
32
|
"@gaonjs/mail": "0.1.3",
|
|
34
|
-
"@gaonjs/web": "0.
|
|
33
|
+
"@gaonjs/web": "0.9.0",
|
|
34
|
+
"@gaonjs/data": "0.12.0",
|
|
35
35
|
"@gaonjs/async": "0.6.1"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|