@gaonjs/cli 0.18.0 → 0.23.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.
Files changed (41) hide show
  1. package/dist/doctor/csrf-wiring.d.ts +5 -0
  2. package/dist/doctor/csrf-wiring.js +72 -0
  3. package/dist/doctor/internal-anchor.d.ts +6 -0
  4. package/dist/doctor/internal-anchor.js +122 -0
  5. package/dist/doctor/method-override.d.ts +5 -0
  6. package/dist/doctor/method-override.js +75 -0
  7. package/dist/doctor/route-registration.d.ts +5 -0
  8. package/dist/doctor/route-registration.js +77 -0
  9. package/dist/doctor/static-collision.d.ts +5 -0
  10. package/dist/doctor/static-collision.js +84 -0
  11. package/dist/doctor/types.d.ts +1 -1
  12. package/dist/doctor/types.js +2 -4
  13. package/dist/doctor.d.ts +2 -0
  14. package/dist/doctor.js +24 -2
  15. package/dist/generate.d.ts +8 -0
  16. package/dist/generate.js +51 -7
  17. package/dist/index.d.ts +6 -2
  18. package/dist/index.js +33 -23
  19. package/dist/serve.d.ts +10 -0
  20. package/dist/serve.js +97 -2
  21. package/dist/templates/auth/Login.vue.tpl +2 -2
  22. package/dist/templates/auth/Signup.vue.tpl +2 -2
  23. package/dist/templates/auth/app.ts.tpl +29 -0
  24. package/dist/templates/auth/server.ts.tpl +14 -0
  25. package/dist/templates/project/.dockerignore.tpl +12 -0
  26. package/dist/templates/project/AGENTS.md.tpl +18 -7
  27. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  28. package/dist/templates/project/Dockerfile.tpl +30 -0
  29. package/dist/templates/project/agents/async.md.tpl +4 -0
  30. package/dist/templates/project/agents/data.md.tpl +37 -15
  31. package/dist/templates/project/agents/frontend.md.tpl +33 -1
  32. package/dist/templates/project/agents/realtime.md.tpl +24 -17
  33. package/dist/templates/project/agents/security.md.tpl +18 -2
  34. package/dist/templates/project/agents/web.md.tpl +47 -0
  35. package/dist/templates/project/apps/web/app.config.ts.tpl +14 -0
  36. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +6 -2
  37. package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
  38. package/dist/templates/project/compose.prod.yaml.tpl +98 -0
  39. package/dist/templates/project/package.json.tpl +1 -0
  40. package/dist/work.js +2 -0
  41. package/package.json +7 -7
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점). */
3
+ export declare function hasStateChangingRoutes(routesSource: string): boolean;
4
+ /** apps/ 를 훑어 CSRF/세션 미배선(비-GET 라우트 + session 없음)을 경고로 낸다. */
5
+ export declare function checkCsrfWiring(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,72 @@
1
+ // @gaonjs/cli · doctor · CSRF/세션 배선 검사 (결정 93)
2
+ //
3
+ // 규칙 8(§2.5.1): 보안 기본값(CORS·rate limit·CSRF)은 코어에서 기본 켬이다.
4
+ // CSRF 는 세션 위에 얹힌다(session.ts) — 세션이 없으면 CSRF 토큰을 저장·검증할
5
+ // 곳이 없어 상태 변경 요청(POST/PUT/PATCH/DELETE)이 **무방비**로 통과한다.
6
+ //
7
+ // 이 검사는 앱 routes.ts 에 비-GET 라우트가 있는데 그 앱 app.config.ts 에
8
+ // session 배선이 없으면 경고한다 — 컴파일은 통과하지만 CSRF 가 실질적으로 꺼진
9
+ // 상태다. JWT/API 앱(strategy:'jwt')은 토큰 인증이라 CSRF 대상이 아니므로 제외한다.
10
+ //
11
+ // 판정은 소스 텍스트 기반(가벼운 정적 검사) — routes.ts·app.config.ts 를 실행하지
12
+ // 않는다. gaon new 스캐폴드는 web 앱에 세션을 기본 배선하므로(결정 93) 관례를
13
+ // 따르면 이 경고는 나오지 않는다.
14
+ import { readdir, readFile } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { join, relative } from 'node:path';
17
+ import { hasSessionWiring } from './auth-wiring.js';
18
+ /** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점). */
19
+ export function hasStateChangingRoutes(routesSource) {
20
+ // r.post('/x', ...) · r.put/patch/delete — 메서드 + 경로 문자열.
21
+ if (/\.\s*(post|put|patch|delete)\s*\(\s*['"]/.test(routesSource))
22
+ return true;
23
+ // resource(s)('posts') — 리소스 라우트는 create/update/destroy(POST/PUT/DELETE)를 만든다.
24
+ if (/\.\s*resources?\s*\(\s*['"]/.test(routesSource))
25
+ return true;
26
+ return false;
27
+ }
28
+ /** apps/ 를 훑어 CSRF/세션 미배선(비-GET 라우트 + session 없음)을 경고로 낸다. */
29
+ export async function checkCsrfWiring(cwd) {
30
+ const appsDir = join(cwd, 'apps');
31
+ const issues = [];
32
+ for (const app of await safeListDirs(appsDir)) {
33
+ const routesPath = join(appsDir, app, 'routes.ts');
34
+ const routesSource = await readFile(routesPath, 'utf8').catch(() => undefined);
35
+ if (routesSource === undefined)
36
+ continue;
37
+ if (!hasStateChangingRoutes(routesSource))
38
+ continue;
39
+ const acPath = join(appsDir, app, 'app.config.ts');
40
+ const acRel = relative(cwd, acPath);
41
+ const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : undefined;
42
+ // JWT/API 앱은 토큰 인증 — CSRF 대상 아님(제외).
43
+ const jwt = acSource != null && /strategy\s*:\s*['"]jwt['"]/.test(acSource);
44
+ if (jwt)
45
+ continue;
46
+ if (acSource != null && hasSessionWiring(acSource))
47
+ continue;
48
+ issues.push({
49
+ rule: 'csrf-wiring',
50
+ level: 'warning',
51
+ file: `apps/${app}/routes.ts`,
52
+ message: `CSRF 미배선: apps/${app} 에 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 ` +
53
+ `${acRel} 에 session 배선이 없습니다 — CSRF 는 세션 위에 얹히므로(규칙 8 · §2.5.1) ` +
54
+ `이대로면 상태 변경 요청이 CSRF 검증 없이 통과합니다(결정 93).\n` +
55
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
56
+ ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' }\n` +
57
+ `→ 루트 gaon.config.ts 에 redis: { url: process.env.REDIS_URL } 이 켜져 있는지 확인하세요(세션 스토어).\n` +
58
+ `→ 토큰 인증 API 앱이라면 app.config 에 auth: { strategy: 'jwt', ... } 를 선언하세요(CSRF 대상 제외).`,
59
+ detail: { app },
60
+ });
61
+ }
62
+ return { rule: 'csrf-wiring', issues };
63
+ }
64
+ async function safeListDirs(dir) {
65
+ try {
66
+ const entries = await readdir(dir, { withFileTypes: true });
67
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ }
@@ -0,0 +1,6 @@
1
+ import type { RuleReport } from './types.js';
2
+ export declare function internalAnchorHref(openTag: string): string | null;
3
+ /** 소스에 내부 이동 일반 앵커가 있는지(단위 테스트 진입점 · 주석은 제외). */
4
+ export declare function usesInternalAnchor(source: string): boolean;
5
+ /** apps/ 의 .vue 를 훑어 내부 이동 일반 앵커를 경고로 낸다. */
6
+ export declare function checkInternalAnchor(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,122 @@
1
+ // @gaonjs/cli · doctor · 앱 내부 이동 일반 <a> 앵커 검출 (결정 96 · 경고)
2
+ //
3
+ // 앱 내부 경로로 이동하는 일반 `<a href="/...">` 는 클릭마다 전체 문서를
4
+ // 다시 로드한다 — Inertia SPA 상태(스크롤·폼·구독)가 초기화되고, "메인
5
+ // 경로" 로 이동할 때마다 풀 리로드가 난다(첫 실사용 블로그에서 관측된 결함).
6
+ // 앱 내부 이동은 `gaonjs/vue` 의 `Link`(선언적) 또는 `router.visit`(프로그램적)
7
+ // 를 쓴다. 이 검사가 내부 경로 일반 앵커를 경고로 잡는다. 판정은 소스 텍스트 기반.
8
+ //
9
+ // 오탐 방지(결정 96): 외부 URL(http/https·//·mailto·tel)·프래그먼트(#)·
10
+ // `target="_blank"`·정적 판별 불가한 바인딩(`:href="expr"` 중 템플릿 리터럴이
11
+ // 아닌 것)은 건드리지 않는다. 내부로 확정 가능한 경우만 — 정적 `href="/..."`
12
+ // 와 `/` 로 시작하는 템플릿 리터럴 `:href="`/...`"` — 잡는다.
13
+ import { readdir, readFile } from 'node:fs/promises';
14
+ import { join, relative } from 'node:path';
15
+ // 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — 안티패턴을
16
+ // "쓰지 말라"고 설명하는 주석(줄/블록/HTML-Vue)이 오탐을 내지 않도록.
17
+ function stripCommentsKeepLines(source) {
18
+ const blank = (m) => m.replace(/[^\n]/g, ' ');
19
+ return source
20
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
21
+ .replace(/<!--[\s\S]*?-->/g, blank)
22
+ .replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
23
+ }
24
+ // 값이 앱 내부 경로(루트-상대)인지. 외부·프래그먼트·스킴은 제외.
25
+ function isInternalStaticHref(value) {
26
+ const v = value.trim();
27
+ if (v === '')
28
+ return false;
29
+ if (v.startsWith('//'))
30
+ return false; // 프로토콜-상대 → 외부
31
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v))
32
+ return false; // http:·mailto:·tel:·data: 등 스킴
33
+ if (v.startsWith('#'))
34
+ return false; // 페이지 내 프래그먼트
35
+ return v.startsWith('/'); // 루트-상대만 내부로 확정
36
+ }
37
+ // 여는 `<a ...>` 태그 하나가 "내부 이동 일반 앵커" 인지 판정해, 그렇다면
38
+ // 문제의 href 문자열을 돌려준다(아니면 null). 정적 판별 불가는 null(오탐 방지).
39
+ export function internalAnchorHref(openTag) {
40
+ // target="_blank" = 외부 의도 → 건드리지 않는다.
41
+ if (/\btarget\s*=\s*['"]_blank['"]/i.test(openTag))
42
+ return null;
43
+ // 정적 href="..."
44
+ const staticM = openTag.match(/\shref\s*=\s*(['"])([^'"]*)\1/i);
45
+ if (staticM)
46
+ return isInternalStaticHref(staticM[2]) ? staticM[2].trim() : null;
47
+ // 바인딩 :href / v-bind:href — 템플릿 리터럴 `/...` 만 내부로 확정.
48
+ const boundM = openTag.match(/(?::|v-bind:)href\s*=\s*(['"])([^'"]*)\1/i);
49
+ if (boundM) {
50
+ const expr = boundM[2].trim();
51
+ const tl = expr.match(/^`([^`]*)`$/); // `...` 전체가 템플릿 리터럴
52
+ if (tl && tl[1].startsWith('/') && !tl[1].startsWith('//'))
53
+ return expr;
54
+ return null; // 바인딩된 식(prop·식별자)은 정적 판별 불가 → 건드리지 않는다
55
+ }
56
+ return null;
57
+ }
58
+ /** 소스에 내부 이동 일반 앵커가 있는지(단위 테스트 진입점 · 주석은 제외). */
59
+ export function usesInternalAnchor(source) {
60
+ const stripped = stripCommentsKeepLines(source);
61
+ for (const m of stripped.matchAll(/<a\b[^>]*>/gi)) {
62
+ if (internalAnchorHref(m[0]) !== null)
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+ /** apps/ 의 .vue 를 훑어 내부 이동 일반 앵커를 경고로 낸다. */
68
+ export async function checkInternalAnchor(cwd) {
69
+ const appsDir = join(cwd, 'apps');
70
+ const issues = [];
71
+ for (const abs of await walkVue(appsDir)) {
72
+ const source = await readFile(abs, 'utf8').catch(() => '');
73
+ const stripped = stripCommentsKeepLines(source);
74
+ const rel = relative(cwd, abs);
75
+ for (const m of stripped.matchAll(/<a\b[^>]*>/gi)) {
76
+ const href = internalAnchorHref(m[0]);
77
+ if (href === null)
78
+ continue;
79
+ const line = stripped.slice(0, m.index ?? 0).split('\n').length;
80
+ issues.push({
81
+ rule: 'internal-anchor',
82
+ level: 'warning',
83
+ file: rel,
84
+ line,
85
+ message: `내부 이동 일반 앵커 발견: ${rel}:${line} 이 \`<a href="${href}">\` 로 앱 내부 경로를 ` +
86
+ `가리킵니다. 일반 \`<a>\` 는 클릭마다 전체 문서를 다시 로드해 Inertia SPA 상태가 ` +
87
+ `초기화됩니다(첫 실사용에서 관측된 결함 · 결정 96).\n` +
88
+ `→ \`gaonjs/vue\` 의 \`Link\` 를 쓰세요: \`import { Link } from 'gaonjs/vue'\` 후 ` +
89
+ `\`<Link href="${href}">…</Link>\`. 프로그램적 이동은 \`router.visit(...)\`. ` +
90
+ `외부 URL·\`target="_blank"\` 만 \`<a>\` 를 유지합니다.`,
91
+ detail: { file: rel, line, href },
92
+ });
93
+ }
94
+ }
95
+ return { rule: 'internal-anchor', issues };
96
+ }
97
+ /** apps/ 하위 .vue(선언·테스트 제외) 절대경로. */
98
+ async function walkVue(dir) {
99
+ const out = [];
100
+ const walk = async (d) => {
101
+ let entries;
102
+ try {
103
+ entries = await readdir(d, { withFileTypes: true });
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ for (const e of entries) {
109
+ const abs = join(d, e.name);
110
+ if (e.isDirectory()) {
111
+ if (e.name === 'node_modules' || e.name === '.gaon')
112
+ continue;
113
+ await walk(abs);
114
+ }
115
+ else if (e.isFile() && e.name.endsWith('.vue')) {
116
+ out.push(abs);
117
+ }
118
+ }
119
+ };
120
+ await walk(dir);
121
+ return out.sort();
122
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 소스에 _method 오버라이드 hack 이 있는지(단위 테스트 진입점 · 주석은 제외). */
3
+ export declare function usesMethodOverride(source: string): boolean;
4
+ /** apps/ 의 .vue·.ts 를 훑어 _method 오버라이드 hack 을 경고로 낸다. */
5
+ export declare function checkMethodOverride(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,75 @@
1
+ // @gaonjs/cli · doctor · _method 오버라이드 hack 검출 (결정 89 · 경고)
2
+ //
3
+ // Gaon 은 `_method`(Rails/Laravel 식 HTTP 메서드 스푸핑)를 지원하지 않는다 —
4
+ // Inertia 는 실 DELETE/PUT/PATCH 를 보낸다. `?_method=DELETE` 쿼리·`<input
5
+ // name="_method">` 폼 필드·`{ _method: 'DELETE' }` 를 쓰면 컴파일은 통과하지만
6
+ // 런타임이 조용히 파손된다(서버가 무시). 이 검사가 그 패턴을 경고로 잡는다.
7
+ // (auth-flow 벤치 (d) 실측 근거 · AGENTS 절대 규칙 6). 판정은 소스 텍스트 기반.
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { join, relative } from 'node:path';
10
+ // 메서드 오버라이드 hack 패턴: 쿼리(?_method=DELETE)·폼 필드(name="_method")·
11
+ // 객체 키(_method: 'DELETE' · 따옴표 유무 무관 · 값이 HTTP 메서드일 때만 — 오탐 방지).
12
+ const METHOD_OVERRIDE = /[?&]_method=|name\s*=\s*['"]_method['"]|['"]?\b_method\b['"]?\s*:\s*['"]?(?:DELETE|PUT|PATCH)/i;
13
+ // 주석을 제거한다(줄 주석·블록 주석·HTML/Vue 주석) — 안티패턴을 "쓰지 말라"고
14
+ // 설명하는 주석이 오탐을 내지 않도록. 실제 실행 코드만 남긴다.
15
+ function stripComments(source) {
16
+ return source
17
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
18
+ .replace(/<!--[\s\S]*?-->/g, ' ')
19
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // // 줄 주석(http:// 는 보존)
20
+ }
21
+ /** 소스에 _method 오버라이드 hack 이 있는지(단위 테스트 진입점 · 주석은 제외). */
22
+ export function usesMethodOverride(source) {
23
+ return METHOD_OVERRIDE.test(stripComments(source));
24
+ }
25
+ /** apps/ 의 .vue·.ts 를 훑어 _method 오버라이드 hack 을 경고로 낸다. */
26
+ export async function checkMethodOverride(cwd) {
27
+ const appsDir = join(cwd, 'apps');
28
+ const issues = [];
29
+ for (const abs of await walkSources(appsDir)) {
30
+ const source = await readFile(abs, 'utf8').catch(() => '');
31
+ if (!usesMethodOverride(source))
32
+ continue;
33
+ issues.push({
34
+ rule: 'method-override',
35
+ level: 'warning',
36
+ file: relative(cwd, abs),
37
+ message: `_method 오버라이드 hack 발견: ${relative(cwd, abs)} 이 \`_method\`(Rails/Laravel 식 HTTP ` +
38
+ `메서드 스푸핑)를 씁니다. Gaon 은 이를 지원하지 않아 컴파일은 통과해도 런타임이 조용히 ` +
39
+ `파손됩니다(서버가 무시 · 결정 89).\n` +
40
+ `→ Gaon 은 \`_method\` 를 지원하지 않습니다. HTML 폼이 못 보내는 메서드는 ` +
41
+ `\`router.delete(...)\` / \`useForm(...).delete(...)\` 를 쓰세요 (AGENTS 절대 규칙 6).`,
42
+ detail: { file: relative(cwd, abs) },
43
+ });
44
+ }
45
+ return { rule: 'method-override', issues };
46
+ }
47
+ /** apps/ 하위 .vue·.ts(선언·테스트 제외) 절대경로. */
48
+ async function walkSources(dir) {
49
+ const out = [];
50
+ const walk = async (d) => {
51
+ let entries;
52
+ try {
53
+ entries = await readdir(d, { withFileTypes: true });
54
+ }
55
+ catch {
56
+ return;
57
+ }
58
+ for (const e of entries) {
59
+ const abs = join(d, e.name);
60
+ if (e.isDirectory()) {
61
+ if (e.name === 'node_modules' || e.name === '.gaon')
62
+ continue;
63
+ await walk(abs);
64
+ }
65
+ else if (e.isFile()) {
66
+ if (e.name.endsWith('.d.ts') || e.name.endsWith('.test.ts'))
67
+ continue;
68
+ if (e.name.endsWith('.vue') || e.name.endsWith('.ts'))
69
+ out.push(abs);
70
+ }
71
+ }
72
+ };
73
+ await walk(dir);
74
+ return out.sort();
75
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
3
+ export declare function referencedControllers(routesSource: string): Set<string>;
4
+ /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
5
+ export declare function checkRouteRegistration(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,77 @@
1
+ // @gaonjs/cli · doctor · 라우트 미등록(고아 컨트롤러) 검사 (결정 79)
2
+ //
3
+ // apps/<앱>/controllers/<name>.ts 가 있는데 그 앱 routes.ts 어디에서도 참조되지
4
+ // 않으면(= `<name>#action` 대상도, `resource(s)('<name>')` 도 없음) 그 컨트롤러는
5
+ // 어떤 URL 로도 도달할 수 없다 — "라우트 미등록" 상태다. 컴파일은 통과하므로
6
+ // check 로는 안 잡히는 조용한 누락이라, 이 검사가 경고로 낸다(라우트가 없는
7
+ // 컨트롤러 파일은 배선을 깜빡한 흔한 실수 · §7.5.3 수리 안내).
8
+ //
9
+ // routes.ts 가 참조하는데 파일이 없는 반대 경우는 이미 generator 가 하드 에러로
10
+ // 잡으므로(gaon check 중 throw) 여기서는 고아 컨트롤러만 본다. 판정은 소스 텍스트
11
+ // 기반(가벼운 정적 검사) — routes.ts 를 실행하지 않는다.
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
15
+ export function referencedControllers(routesSource) {
16
+ const names = new Set();
17
+ // 메서드 대상: r.get('/', 'posts#index') → 'posts'
18
+ for (const m of routesSource.matchAll(/['"]([A-Za-z_]\w*)#\w+['"]/g))
19
+ names.add(m[1]);
20
+ // 리소스: r.resource('posts') · r.resources('posts')
21
+ for (const m of routesSource.matchAll(/\bresources?\s*\(\s*['"]([A-Za-z_]\w*)['"]/g))
22
+ names.add(m[1]);
23
+ return names;
24
+ }
25
+ /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
26
+ export async function checkRouteRegistration(cwd) {
27
+ const appsDir = join(cwd, 'apps');
28
+ const issues = [];
29
+ for (const app of await safeListDirs(appsDir)) {
30
+ const routesPath = join(appsDir, app, 'routes.ts');
31
+ const routesSource = await readFile(routesPath, 'utf8').catch(() => undefined);
32
+ // routes.ts 가 없으면 참조 집합을 알 수 없어 diff 불가 — 건너뛴다
33
+ // (routes.ts 자체 부재는 다른 층의 관심사).
34
+ if (routesSource === undefined)
35
+ continue;
36
+ const referenced = referencedControllers(routesSource);
37
+ const controllersDir = join(appsDir, app, 'controllers');
38
+ for (const stem of await controllerStems(controllersDir)) {
39
+ if (referenced.has(stem))
40
+ continue;
41
+ issues.push({
42
+ rule: 'route-registration',
43
+ level: 'warning',
44
+ file: `apps/${app}/controllers/${stem}.ts`,
45
+ message: `라우트 미등록: apps/${app}/controllers/${stem}.ts 가 있는데 ` +
46
+ `apps/${app}/routes.ts 어디에서도 참조되지 않습니다 — 이 컨트롤러는 어떤 URL 로도 ` +
47
+ `도달할 수 없습니다(결정 79).\n` +
48
+ `→ apps/${app}/routes.ts 에 \`r.get('/${stem}', '${stem}#index')\`(또는 \`r.resources('${stem}')\`)를 ` +
49
+ `추가하거나, 쓰지 않는 컨트롤러라면 파일을 지우세요.`,
50
+ detail: { app, controller: stem },
51
+ });
52
+ }
53
+ }
54
+ return { rule: 'route-registration', issues };
55
+ }
56
+ /** apps/<앱>/controllers/*.ts 의 stem 목록(선언·테스트 제외). */
57
+ async function controllerStems(dir) {
58
+ try {
59
+ const entries = await readdir(dir, { withFileTypes: true });
60
+ return entries
61
+ .filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.d.ts') && !e.name.endsWith('.test.ts'))
62
+ .map((e) => e.name.slice(0, -'.ts'.length))
63
+ .sort();
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ async function safeListDirs(dir) {
70
+ try {
71
+ const entries = await readdir(dir, { withFileTypes: true });
72
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에서 리터럴 라우트 경로 집합을 뽑는다(단위 테스트 진입점). */
3
+ export declare function routePaths(routesSource: string): Set<string>;
4
+ /** apps/ 를 훑어 라우트/에셋에 가려지는 정적 파일을 경고로 낸다. */
5
+ export declare function checkStaticCollision(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,84 @@
1
+ // @gaonjs/cli · doctor · 정적 파일 ↔ 예약 경로 충돌 검사 (결정 85 · 경고)
2
+ //
3
+ // apps/<앱>/static/ 의 파일은 앱 prefix 아래로 서빙되지만, **명시 라우트와
4
+ // /assets/ 는 항상 우선**한다(결정 85 · appStatic 은 폴백). 따라서 라우트가
5
+ // 이미 쓰는 경로(또는 /assets/*)에 같은 이름의 정적 파일을 두면 그 파일은
6
+ // **영원히 도달 불가**(조용히 라우트에 가려짐)다. 이 검사가 그 상태를 경고한다.
7
+ // 판정은 소스 텍스트 기반(정적 · routes.ts 를 실행하지 않는다).
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ /** routes.ts 소스에서 리터럴 라우트 경로 집합을 뽑는다(단위 테스트 진입점). */
11
+ export function routePaths(routesSource) {
12
+ const paths = new Set();
13
+ // r.get('/path', ...) · r.post("/x/:id", ...) 등 — 첫 문자열 인자가 경로.
14
+ for (const m of routesSource.matchAll(/\br\.(?:get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g)) {
15
+ paths.add(m[1]);
16
+ }
17
+ // resource(s)('name') → REST 표준 경로의 콜리전 후보(/name).
18
+ for (const m of routesSource.matchAll(/\bresources?\s*\(\s*['"]([A-Za-z_]\w*)['"]/g)) {
19
+ paths.add(`/${m[1]}`);
20
+ }
21
+ return paths;
22
+ }
23
+ /** apps/ 를 훑어 라우트/에셋에 가려지는 정적 파일을 경고로 낸다. */
24
+ export async function checkStaticCollision(cwd) {
25
+ const appsDir = join(cwd, 'apps');
26
+ const issues = [];
27
+ for (const app of await safeListDirs(appsDir)) {
28
+ const staticDir = join(appsDir, app, 'static');
29
+ const files = await listFiles(staticDir, staticDir);
30
+ if (files.length === 0)
31
+ continue;
32
+ const routesSource = await readFile(join(appsDir, app, 'routes.ts'), 'utf8').catch(() => '');
33
+ const routes = routePaths(routesSource);
34
+ for (const rel of files) {
35
+ const urlPath = `/${rel}`;
36
+ const shadowedByRoute = routes.has(urlPath);
37
+ const shadowedByAssets = rel === 'assets' || rel.startsWith('assets/');
38
+ if (!shadowedByRoute && !shadowedByAssets)
39
+ continue;
40
+ const by = shadowedByAssets ? '프론트 에셋(/assets/*)' : `라우트 ${urlPath}`;
41
+ issues.push({
42
+ rule: 'static-collision',
43
+ level: 'warning',
44
+ file: `apps/${app}/static/${rel}`,
45
+ message: `정적 파일 가려짐: apps/${app}/static/${rel} 은 ${by} 에 가려져 서빙되지 않습니다 ` +
46
+ `(결정 85 · 라우트·에셋이 정적 폴더보다 우선).\n` +
47
+ `→ 정적 파일 이름을 바꾸거나, 그 경로를 라우트로 직접 응답하세요.`,
48
+ detail: { app, path: urlPath },
49
+ });
50
+ }
51
+ }
52
+ return { rule: 'static-collision', issues };
53
+ }
54
+ /** staticDir 하위 파일을 상대 경로(posix)로 나열. */
55
+ async function listFiles(dir, base) {
56
+ const out = [];
57
+ const walk = async (d) => {
58
+ let entries;
59
+ try {
60
+ entries = await readdir(d, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const e of entries) {
66
+ const abs = join(d, e.name);
67
+ if (e.isDirectory())
68
+ await walk(abs);
69
+ else if (e.isFile())
70
+ out.push(abs.slice(base.length + 1).split('\\').join('/'));
71
+ }
72
+ };
73
+ await walk(dir);
74
+ return out;
75
+ }
76
+ async function safeListDirs(dir) {
77
+ try {
78
+ const entries = await readdir(dir, { withFileTypes: true });
79
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
@@ -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';
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';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
@@ -1,9 +1,7 @@
1
1
  // @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
2
2
  //
3
- // 14 검사(response-mixing · n-plus-one · dependency-direction · connections
4
- // · migration-diff · shared-composable-purity · no-auto-import · schema-filename
5
- // · agents-doc-index · column-casing · model-filename · page-filename · auth-wiring
6
- // · ui-kit-wiring)가 모두 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
3
+ // 19 검사(전체 목록은 AGENTS §2.2 · doctor.ts `ALL_RULES`)가 모두 이 DoctorCheck
4
+ // 낸다. 상위(runDoctorCommand)는 level passed/
7
5
  // warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
8
6
  // errors.length > 0 이면 fail 로 판단한다.
9
7
  //
package/dist/doctor.d.ts CHANGED
@@ -15,6 +15,8 @@ export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
15
15
  export { checkPageFilename } from './doctor/page-filename.js';
16
16
  export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
17
17
  export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
18
+ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
19
+ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
18
20
  export { renderHuman, renderJson } from './doctor/reporter.js';
19
21
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
20
22
  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
- * 14 검사를 조립한다:
4
+ * 19 검사를 조립한다:
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 규칙)
@@ -16,6 +16,11 @@
16
16
  * 12) page-filename (§3.4 · 결정 32·46 · Vue 페이지 파일명 PascalCase)
17
17
  * 13) auth-wiring (§7 · 결정 59 · requireAuth ↔ app.config.ts auth 배선)
18
18
  * 14) ui-kit-wiring (§6.4 · 결정 76 · UI 킷 import ↔ apps/<앱>/style.css Tailwind 배선)
19
+ * 15) route-registration (결정 79 · 고아 컨트롤러 = routes.ts 미참조 경고)
20
+ * 16) static-collision (결정 85 · 정적 파일이 라우트/에셋에 가려짐 경고)
21
+ * 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
22
+ * 18) csrf-wiring (결정 93 · 비-GET 라우트 + session 미배선 = CSRF 무방비 경고)
23
+ * 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
19
24
  *
20
25
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
21
26
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -42,6 +47,11 @@ import { checkModelFilename } from './doctor/model-filename.js';
42
47
  import { checkPageFilename } from './doctor/page-filename.js';
43
48
  import { checkAuthWiring } from './doctor/auth-wiring.js';
44
49
  import { checkUiKitWiring } from './doctor/ui-kit-wiring.js';
50
+ import { checkRouteRegistration } from './doctor/route-registration.js';
51
+ import { checkStaticCollision } from './doctor/static-collision.js';
52
+ import { checkMethodOverride } from './doctor/method-override.js';
53
+ import { checkCsrfWiring } from './doctor/csrf-wiring.js';
54
+ import { checkInternalAnchor } from './doctor/internal-anchor.js';
45
55
  import { renderHuman, renderJson } from './doctor/reporter.js';
46
56
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
47
57
  import { makeResult, } from './doctor/types.js';
@@ -60,10 +70,12 @@ export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
60
70
  export { checkPageFilename } from './doctor/page-filename.js';
61
71
  export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
62
72
  export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
73
+ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
74
+ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
63
75
  export { renderHuman, renderJson } from './doctor/reporter.js';
64
76
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
65
77
  /**
66
- * 실행할 검사 이름. 지정 없음(undefined) = 14개 모두.
78
+ * 실행할 검사 이름. 지정 없음(undefined) = 19개 모두.
67
79
  */
68
80
  const ALL_RULES = [
69
81
  'response-mixing',
@@ -80,6 +92,11 @@ const ALL_RULES = [
80
92
  'page-filename',
81
93
  'auth-wiring',
82
94
  'ui-kit-wiring',
95
+ 'route-registration',
96
+ 'static-collision',
97
+ 'method-override',
98
+ 'csrf-wiring',
99
+ 'internal-anchor',
83
100
  ];
84
101
  const CHECKERS = {
85
102
  'response-mixing': checkResponseMixing,
@@ -96,6 +113,11 @@ const CHECKERS = {
96
113
  'page-filename': checkPageFilename,
97
114
  'auth-wiring': checkAuthWiring,
98
115
  'ui-kit-wiring': checkUiKitWiring,
116
+ 'route-registration': checkRouteRegistration,
117
+ 'static-collision': checkStaticCollision,
118
+ 'method-override': checkMethodOverride,
119
+ 'csrf-wiring': checkCsrfWiring,
120
+ 'internal-anchor': checkInternalAnchor,
99
121
  };
100
122
  /**
101
123
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
@@ -21,6 +21,14 @@ export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldF
21
21
  * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
22
22
  */
23
23
  export declare function patchRoutes(existing: string): string | null;
24
+ /**
25
+ * 기존 app.config.ts 에 auth 배선을 끼워 넣는다 (결정 93 · gaon new 기본 web 앱은
26
+ * 세션만 배선돼 있어 g auth 는 auth 만 추가하면 완성된다). 반환:
27
+ * · auth 가 이미 있으면 null (그대로 둔다),
28
+ * · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
29
+ * 성공 시 loadUser import 도 함께 보장한다.
30
+ */
31
+ export declare function patchAppConfigAddAuth(existing: string): string | null;
24
32
  /** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
25
33
  export declare function writeAuthScaffold(cwd: string, opts?: AuthScaffoldOptions): ScaffoldResult;
26
34
  export interface GenerateAuthOptions {