@gaonjs/cli 0.26.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.
@@ -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
+ }
@@ -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
- * 22 검사를 조립한다:
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) = 21개 모두.
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
@@ -101,7 +101,7 @@ function renderHelp(version = VERSION) {
101
101
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
102
102
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
103
103
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
104
- " gaon doctor 정적 검사 (22 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트)",
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 { pageProps, router } from 'gaonjs/vue'
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
- // dashboard#show render props user 직렬화되며 passwordDigest 없다(§4.2).
12
- // pageProps 반응형 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
13
- const props = pageProps<'{{APP_NAME}}:dashboard#show'>()
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 를 보낼 없으므로 Inertia 라우터로 실제 메서드를
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': props.csrf } })
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>환영합니다, {{ props.user.name }}님</CardTitle>
29
- <CardDescription>{{ props.user.email }}</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: props.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: props.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
- const user = this.requireAuth()
8
- // csrf 로그아웃(DELETE /session · 결정 64)이 헤더로 실어 보낸다.
9
- return this.render('Dashboard', { user, csrf: this.csrfToken() })
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, csrf: this.csrfToken() })
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, csrf: this.csrfToken() })
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` 검사 22
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 # 정적 검사 22종 (§2.2)
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 # 정적 검사 17종 (응답·N+1·의존·커넥션·마이그·순수·자동import·파일명/컬럼·인증·UI킷·라우트 · 상세 AGENTS §2.2)
88
+ gaon doctor # 정적 검사 23종 (상세 AGENTS §2.2)
89
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
90
  ```
91
91
 
@@ -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` 으로 **스키마 파생 폼**을 노출한다 — 컨트롤러의
@@ -652,4 +692,6 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
652
692
  | 결정 104 | 스키마 파생 폼에 name·defs 탑재(실검증) · `Model.form.pick()` 검증되는 부분 폼(§8.1) |
653
693
  | 결정 108 | 정적 default 컬럼 빈 입력 채움(§8.1) · 동적 default 는 DB 위임 |
654
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) |
655
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 + 서버
@@ -258,6 +268,12 @@ import PageShell from '@shared/components/ui/PageShell.vue'
258
268
  - **`class` 는 폴스루로 병합** — 단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
259
269
  넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
260
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 중첩을 경고한다.
261
277
  - **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
262
278
  `text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
263
279
  `:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
@@ -367,4 +383,6 @@ async function runSearch(q: string) {
367
383
  | 결정 106 | 최소 4블록(PageShell·PageHeader·EmptyState·Pagination · 성격 중립) |
368
384
  | 결정 107 | 반응형은 킷 책임(페이지 레이아웃 브레이크포인트 지양 · doctor page-layout-breakpoint 안내 경고 · 터치 44px·폰트 최소 크기 토큰) |
369
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`) |
370
388
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
@@ -167,12 +167,14 @@ 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 form = useForm({ email: '', password: '', _csrf: props.csrf })
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': props.csrf } })
177
+ router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
176
178
  ```
177
179
 
178
180
  `?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
@@ -203,6 +205,44 @@ async create() {
203
205
 
204
206
  순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
205
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
+
206
246
  ### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
207
247
 
208
248
  회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
@@ -372,4 +412,7 @@ export default controller({
372
412
  | 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
373
413
  | 결정 108 | 정적 default 컬럼 빈 입력 채움(coerceParams) · 동적 default 는 DB 위임 |
374
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) |
375
418
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
@@ -1,14 +1,32 @@
1
1
  <script setup lang="ts">
2
- // UI 킷 · Button (결정 75). variant/size 로 모양을 고르고, 나머지 속성·클래스는
3
- // 자연스럽게 <button> 으로 흘러간다(Vue 폴스루). 부모가 class 를 주면 뒤에 병합된다.
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<{ variant?: Variant; size?: Size; type?: 'button' | 'submit' | 'reset' }>(),
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
- <button :type="type" :class="classes"><slot /></button>
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.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.6.1",
31
- "@gaonjs/config": "0.6.0",
30
+ "@gaonjs/config": "0.7.0",
32
31
  "@gaonjs/core": "0.2.1",
33
- "@gaonjs/web": "0.8.0",
34
- "@gaonjs/data": "0.11.0",
35
- "@gaonjs/mail": "0.1.3"
32
+ "@gaonjs/mail": "0.1.3",
33
+ "@gaonjs/web": "0.9.0",
34
+ "@gaonjs/data": "0.12.0",
35
+ "@gaonjs/async": "0.6.1"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
@@ -1,29 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
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} 에서 실행 중입니다.`)