@gaonjs/cli 0.55.0 → 0.56.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.
@@ -94,8 +94,19 @@ async function provisionTestDatabases(cwd, json, onOutput) {
94
94
  if (dbs.length === 0)
95
95
  return true;
96
96
  const prepared = [];
97
+ // 결정 279·292: 문서형(mongodb)은 SQL 프로비저닝(CREATE DATABASE + 마이그레이션)이 없다 —
98
+ // 순회에서 건너뛰고 보고에만 남긴다. 이 skip 이 없으면 SQL 전용 deriveTestDatabaseConfig/
99
+ // ensureTestDatabaseExists 가 몽고 커넥션에 pg/mysql 드라이버로 붙으려다 throw 해,
100
+ // 몽고 커넥션이 하나라도 있는 프로젝트는 `gaon test` 가 테스트 실행 전에 즉사했다
101
+ // (testkit·`gaon db` 는 이미 건너뛰는데 CLI test 만 빠진 비대칭). 테스트 레인의 몽고
102
+ // 커넥션 자체는 connectTestDatabase(@gaonjs/config testkit)가 등록한다.
103
+ const skippedMongo = [];
97
104
  try {
98
105
  for (const [key, cfg] of dbs) {
106
+ if (cfg.adapter === 'mongodb') {
107
+ skippedMongo.push(key);
108
+ continue;
109
+ }
99
110
  const testCfg = deriveTestDatabaseConfig(cfg);
100
111
  await ensureTestDatabaseExists(testCfg);
101
112
  const res = await runDbMigrate({
@@ -130,10 +141,23 @@ async function provisionTestDatabases(cwd, json, onOutput) {
130
141
  finally {
131
142
  await destroyAllConnections();
132
143
  }
133
- if (json)
134
- writeOut(JSON.stringify({ kind: 'provisioned', dbs: prepared }) + '\n');
135
- else
136
- writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
144
+ if (json) {
145
+ writeOut(JSON.stringify({
146
+ kind: 'provisioned',
147
+ dbs: prepared,
148
+ skipped: skippedMongo.map((db) => ({ db, adapter: 'mongodb', reason: 'documental-no-migration' })),
149
+ }) + '\n');
150
+ }
151
+ else {
152
+ // SQL 커넥션이 하나도 없으면(문서형 전용 프로젝트) "준비 완료 ()" 대신 건너뜀만 알린다.
153
+ if (prepared.length > 0) {
154
+ writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
155
+ }
156
+ if (skippedMongo.length > 0) {
157
+ writeOut(` gaon test · 문서형(mongodb) 커넥션 건너뜀: ${skippedMongo.join(', ')} — 문서형은 마이그레이션이 없습니다(§7.4).\n` +
158
+ ` → 테스트 레인의 몽고 커넥션은 test/setup.ts 의 connectTestDatabase() 가 <db>_test 로 등록합니다(결정 279).\n`);
159
+ }
160
+ }
137
161
  return true;
138
162
  }
139
163
  /**
@@ -12,6 +12,7 @@ import { readdir, readFile } from 'node:fs/promises';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join, relative } from 'node:path';
14
14
  import { stripComments, stripCommentsAndStrings } from './source-scan.js';
15
+ import { jwtSecretEnvFor } from '../generate.js';
15
16
  // 결정 140(12차 W1): 판정 전 주석/문자열을 지운다. `// session:` 주석이나 문자열
16
17
  // 안의 우연한 일치가 배선으로 오탐되면 보안 검사(세션·CSRF)가 통째로 skip 된다.
17
18
  /** 컨트롤러 소스가 인증 표면을 쓰는지 판정한다(단위 테스트 진입점). */
@@ -57,10 +58,12 @@ export async function checkAuthWiring(cwd) {
57
58
  file: authFiles[0],
58
59
  message: `인증 배선 누락: apps/${app} 컨트롤러(${authFiles.join(', ')})가 requireAuth/this.auth 를 쓰는데 ` +
59
60
  `${acRel} 에 auth 배선이 없습니다. 이대로면 로그인해도 currentUser 가 항상 null 입니다(결정 59).\n` +
60
- `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
61
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요(세션 앱 · 정본):\n` +
61
62
  ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' },\n` +
62
63
  ` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
63
- `→ loadUser 없으면 \`gaon g auth --app ${app}\` 스캐폴드가 apps/${app}/auth.ts 에 만들어 줍니다.`,
64
+ `→ API(JWT) 앱이라면 세션 대신 토큰 배선입니다(결정 337·338 세션·loginRedirect 없음):\n` +
65
+ ` auth: { strategy: 'jwt', secret: process.env.${jwtSecretEnvFor(app)} ?? '32자 이상 dev 비밀', loadUser }\n` +
66
+ `→ loadUser 가 없으면 \`gaon g auth --app ${app}\`(세션) 또는 \`gaon g auth --jwt --app ${app}\`(JWT) 스캐폴드가 apps/${app}/auth.ts 에 만들어 줍니다.`,
64
67
  detail: { app, controllers: authFiles },
65
68
  });
66
69
  }
@@ -0,0 +1,9 @@
1
+ import type { RuleReport } from './types.js';
2
+ /**
3
+ * 소스가 default 를 다른 모듈에서 그대로 재수출만 하는지 판정하고 그 모듈 지정자를 낸다.
4
+ * `export { default } from '...'` · `export { default as default } from '...'` ·
5
+ * `export * from '...'` 를 인정한다(주석 제외 후 판정).
6
+ */
7
+ export declare function reexportSpecifier(source: string): string | undefined;
8
+ /** apps/ 를 훑어 앱간 동명 채널을 낸다. */
9
+ export declare function checkChannelCollision(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,119 @@
1
+ // @gaonjs/cli · doctor · 앱간 동명 채널 검사 (§7 실시간)
2
+ //
3
+ // 채널 이름은 **전역 네임스페이스**다. 브로드캐스트 subject(`gaon.chan.<이름>`)와
4
+ // 프레즌스 KV 키(`presence.<이름>.<멤버>`) 어디에도 앱 프리픽스가 없다(async/keys.ts).
5
+ // 그래서 `apps/web/channels/room.ts` 와 `apps/admin/channels/room.ts` 처럼 두 앱이
6
+ // 같은 파일명(=채널명)으로 **각자** 채널을 정의하면:
7
+ // · 한쪽 앱에서 broadcast 한 메시지가 다른 앱 연결로도 팬아웃되고,
8
+ // · 접속자 목록(presence)이 두 앱 것으로 병합되며,
9
+ // · 두 정의의 authorize/presenceInfo 가 갈려 한쪽의 공개 규칙이 다른 앱의 비공개
10
+ // 메시지를 새게 한다(연결이 어느 앱 WS 로 붙었느냐로 훅이 갈리기 때문).
11
+ // 잡·리스너의 동명 등록은 런타임에 throw 로 막지만(결정 271), 채널은 앱별 맵에
12
+ // 따로 담겨 런타임 충돌 신호가 없다 — 그래서 정적 검사로 잡는다.
13
+ //
14
+ // 의도적 공유의 탈출구: 정의를 `shared/channels/<이름>.ts` 하나에 두고 각 앱 채널
15
+ // 파일이 **재수출**하면 통과한다(정의 하나 = 훅·인가 규칙 하나 · shared 는 앱을
16
+ // 모르므로 의존 방향 4규칙에도 맞다).
17
+ //
18
+ // 판정은 소스 텍스트 기반(가벼운 정적 검사) — 채널 모듈을 실행하지 않는다.
19
+ import { readdir, readFile } from 'node:fs/promises';
20
+ import { join, relative, resolve, dirname } from 'node:path';
21
+ import { stripComments } from './source-scan.js';
22
+ /**
23
+ * 소스가 default 를 다른 모듈에서 그대로 재수출만 하는지 판정하고 그 모듈 지정자를 낸다.
24
+ * `export { default } from '...'` · `export { default as default } from '...'` ·
25
+ * `export * from '...'` 를 인정한다(주석 제외 후 판정).
26
+ */
27
+ export function reexportSpecifier(source) {
28
+ const src = stripComments(source);
29
+ const named = src.match(/export\s*\{\s*default(?:\s+as\s+default)?\s*\}\s*from\s*['"]([^'"]+)['"]/);
30
+ if (named)
31
+ return named[1];
32
+ const star = src.match(/export\s*\*\s*from\s*['"]([^'"]+)['"]/);
33
+ if (star)
34
+ return star[1];
35
+ return undefined;
36
+ }
37
+ /** 모듈 지정자를 파일 기준 절대경로로 해석한다(ESM `.js` → 소스 `.ts` 정규화). */
38
+ function resolveSpecifier(fromFile, spec) {
39
+ if (!spec.startsWith('.'))
40
+ return undefined; // 패키지 지정자는 동일성 판정 불가.
41
+ const abs = resolve(dirname(fromFile), spec);
42
+ return abs.replace(/\.js$/, '.ts');
43
+ }
44
+ /** apps/ 를 훑어 앱간 동명 채널을 낸다. */
45
+ export async function checkChannelCollision(cwd) {
46
+ const appsDir = join(cwd, 'apps');
47
+ // 채널 이름 → 정의 파일들.
48
+ const byName = new Map();
49
+ for (const app of await safeListDirs(appsDir)) {
50
+ const chDir = join(appsDir, app, 'channels');
51
+ for (const file of await safeListFiles(chDir)) {
52
+ if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
53
+ continue;
54
+ const abs = join(chDir, file);
55
+ const name = file.slice(0, -3);
56
+ const spec = reexportSpecifier(await readFile(abs, 'utf8'));
57
+ const entry = {
58
+ app,
59
+ rel: relative(cwd, abs),
60
+ reexportTarget: spec ? resolveSpecifier(abs, spec) : undefined,
61
+ };
62
+ const list = byName.get(name);
63
+ if (list)
64
+ list.push(entry);
65
+ else
66
+ byName.set(name, [entry]);
67
+ }
68
+ }
69
+ const issues = [];
70
+ for (const [name, entries] of [...byName.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
71
+ if (entries.length < 2)
72
+ continue;
73
+ // readdir 순서에 보고가 흔들리지 않게 앱 이름으로 고정한다.
74
+ const files = [...entries].sort((a, b) => (a.app < b.app ? -1 : a.app > b.app ? 1 : 0));
75
+ // 전부 같은 모듈을 재수출하면 정의는 하나다 — 의도적 공유로 인정한다.
76
+ const targets = files.map((f) => f.reexportTarget);
77
+ if (targets.every((t) => t !== undefined && t === targets[0]))
78
+ continue;
79
+ const apps = files.map((f) => f.app).join(', ');
80
+ const list = files.map((f) => f.rel).join(' · ');
81
+ // 이름 변경을 권할 대상 — web 은 프리픽스 '/' 의 기본 앱이라 되도록 그대로 두고
82
+ // 비-web 앱 쪽 이름을 바꾸도록 제안한다(둘 다 비-web 이면 두 번째).
83
+ const target = files.find((f) => f.app !== 'web') ?? files[1];
84
+ const renamed = `${target.app}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
85
+ issues.push({
86
+ rule: 'channel-collision',
87
+ level: 'error',
88
+ file: files[0].rel,
89
+ message: `앱간 동명 채널 '${name}': ${apps} 앱이 각각 정의합니다(${list}).\n` +
90
+ `채널 이름은 전역입니다 — 브로드캐스트 subject(gaon.chan.${name})와 프레즌스 키에 앱 프리픽스가 ` +
91
+ `없어 두 앱의 연결이 같은 채널로 팬아웃되고 접속자 목록이 병합됩니다. 두 정의의 authorize 가 다르면 ` +
92
+ `한쪽의 공개 규칙으로 다른 앱 메시지가 샙니다.\n` +
93
+ `→ 앱마다 이름을 분리하세요: apps/${target.app}/channels/${renamed}.ts ` +
94
+ `(클라이언트 useChannel('${renamed}') 도 함께 바꿉니다).\n` +
95
+ `→ 일부러 공유하는 채널이면 정의를 shared/channels/${name}.ts 하나에 두고 각 앱 채널 파일에서 재수출하세요:\n` +
96
+ ` export { default } from '../../../shared/channels/${name}.js'`,
97
+ detail: { channel: name, apps: files.map((f) => f.app), files: files.map((f) => f.rel) },
98
+ });
99
+ }
100
+ return { rule: 'channel-collision', issues };
101
+ }
102
+ async function safeListDirs(dir) {
103
+ try {
104
+ const entries = await readdir(dir, { withFileTypes: true });
105
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ async function safeListFiles(dir) {
112
+ try {
113
+ const entries = await readdir(dir, { withFileTypes: true });
114
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
115
+ }
116
+ catch {
117
+ return [];
118
+ }
119
+ }
@@ -14,7 +14,7 @@ export declare const FIXERS: Partial<Record<DoctorRule, Fixer>>;
14
14
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
15
15
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
16
16
  *
17
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 28종을 **빠짐없이** 담는다 —
17
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 29종을 **빠짐없이** 담는다 —
18
18
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
19
19
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
20
20
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -28,7 +28,7 @@ export const FIXERS = {
28
28
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
29
29
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
30
30
  *
31
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 28종을 **빠짐없이** 담는다 —
31
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 29종을 **빠짐없이** 담는다 —
32
32
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
33
33
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
34
34
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -175,4 +175,9 @@ export const FIXER_CAPABILITIES = [
175
175
  hasFixer: false,
176
176
  note: '수동 · 로케일 간 누락 키는 각 카탈로그(locales/*.json)에 채우세요 — 번역문은 사람이 작성합니다(결정 216).',
177
177
  },
178
+ {
179
+ rule: 'channel-collision',
180
+ hasFixer: false,
181
+ note: '수동 · 앱마다 채널 이름을 분리(파일명 + 클라이언트 useChannel 인자 동시 변경)하거나, 의도적 공유면 정의를 shared/channels/ 하나로 옮기고 각 앱에서 재수출하세요 — 어느 쪽인지는 설계 판단이라 자동 정정하지 않습니다.',
182
+ },
178
183
  ];
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-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' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -24,17 +24,25 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
24
24
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
25
25
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
26
26
  export { checkLocaleParity } from './doctor/locale-parity.js';
27
+ export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
27
28
  export { renderHuman, renderJson } from './doctor/reporter.js';
28
29
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
29
30
  /**
30
- * 실행할 검사 이름. 지정 없음(undefined) = 28개 모두.
31
+ * 실행할 검사 이름. 지정 없음(undefined) = 29개 모두.
31
32
  */
32
33
  /**
33
- * doctor 정적 검사 28종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
34
+ * doctor 정적 검사 29종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
34
35
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
35
36
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
36
37
  */
37
38
  export declare const ALL_RULES: readonly DoctorRule[];
39
+ /**
40
+ * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
41
+ * `Record<DoctorRule, …>` 라 **규칙을 늘리면 여기도 채워야 컴파일된다** — 손유지
42
+ * 산문 열거가 뒤처져 새 검사가 help 에서 통째로 빠지던 표류를 컴파일 에러로 바꾼다
43
+ * (channel-collision 편입 때 실제로 한 곳이 누락됐다).
44
+ */
45
+ export declare const RULE_SUMMARIES: Record<DoctorRule, string>;
38
46
  export interface DoctorCommandOptions {
39
47
  readonly cwd?: string;
40
48
  readonly json?: boolean;
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
- * 28 검사를 조립한다:
4
+ * 29 검사를 조립한다:
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 규칙)
@@ -30,6 +30,7 @@
30
30
  * 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
31
31
  * 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
32
32
  * 28) render-return (결정 340 · this.render/redirect/json 호출만 하고 return 누락 = 무신호 204 경고)
33
+ * 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
33
34
  *
34
35
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
35
36
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -70,6 +71,7 @@ import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
70
71
  import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
71
72
  import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
72
73
  import { checkLocaleParity } from './doctor/locale-parity.js';
74
+ import { checkChannelCollision } from './doctor/channel-collision.js';
73
75
  import { renderHuman, renderJson } from './doctor/reporter.js';
74
76
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
75
77
  import { makeResult, } from './doctor/types.js';
@@ -97,13 +99,14 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
97
99
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
98
100
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
99
101
  export { checkLocaleParity } from './doctor/locale-parity.js';
102
+ export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
100
103
  export { renderHuman, renderJson } from './doctor/reporter.js';
101
104
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
102
105
  /**
103
- * 실행할 검사 이름. 지정 없음(undefined) = 28개 모두.
106
+ * 실행할 검사 이름. 지정 없음(undefined) = 29개 모두.
104
107
  */
105
108
  /**
106
- * doctor 정적 검사 28종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
109
+ * doctor 정적 검사 29종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
107
110
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
108
111
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
109
112
  */
@@ -136,7 +139,45 @@ export const ALL_RULES = [
136
139
  'no-import-meta-env',
137
140
  'locale-parity',
138
141
  'render-return',
142
+ 'channel-collision',
139
143
  ];
144
+ /**
145
+ * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
146
+ * `Record<DoctorRule, …>` 라 **규칙을 늘리면 여기도 채워야 컴파일된다** — 손유지
147
+ * 산문 열거가 뒤처져 새 검사가 help 에서 통째로 빠지던 표류를 컴파일 에러로 바꾼다
148
+ * (channel-collision 편입 때 실제로 한 곳이 누락됐다).
149
+ */
150
+ export const RULE_SUMMARIES = {
151
+ 'response-mixing': '응답 혼용',
152
+ 'n-plus-one': 'N+1',
153
+ 'dependency-direction': '의존 방향',
154
+ connections: '커넥션',
155
+ 'migration-diff': '마이그 diff',
156
+ 'shared-purity': 'shared 순수',
157
+ 'no-auto-import': '자동 import',
158
+ 'schema-filename': '스키마 파일명',
159
+ 'agents-doc-index': 'AGENTS 색인',
160
+ 'column-casing': '컬럼 casing',
161
+ 'model-filename': '모델 파일명',
162
+ 'page-filename': '페이지 파일명',
163
+ 'auth-wiring': '인증 배선',
164
+ 'ui-kit-wiring': 'UI 킷 배선',
165
+ 'route-registration': '라우트 등록',
166
+ 'static-collision': '정적 충돌',
167
+ 'method-override': '_method',
168
+ 'csrf-wiring': 'CSRF 배선',
169
+ 'internal-anchor': '내부 앵커',
170
+ 'pageprops-destructure': 'pageProps 구조분해',
171
+ 'async-offload': '비동기 오프로드',
172
+ 'page-layout-breakpoint': '페이지 레이아웃 브레이크포인트',
173
+ 'link-button-nesting': 'Link>Button 중첩',
174
+ 'seal-security': 'seal 클라 배선·보안 역전',
175
+ 'schema-relations': '§4.5 관계',
176
+ 'no-import-meta-env': 'import.meta.env',
177
+ 'locale-parity': '로케일 커버리지',
178
+ 'render-return': 'render return 누락',
179
+ 'channel-collision': '앱간 동명 채널',
180
+ };
140
181
  const CHECKERS = {
141
182
  'response-mixing': checkResponseMixing,
142
183
  'n-plus-one': checkNPlusOne,
@@ -166,6 +207,7 @@ const CHECKERS = {
166
207
  'no-import-meta-env': checkNoImportMetaEnv,
167
208
  'locale-parity': checkLocaleParity,
168
209
  'render-return': checkRenderReturn,
210
+ 'channel-collision': checkChannelCollision,
169
211
  };
170
212
  /**
171
213
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
@@ -32,6 +32,11 @@ export interface ScaffoldResult {
32
32
  */
33
33
  readonly incomplete: boolean;
34
34
  }
35
+ /**
36
+ * 결정 338: 앱별 JWT secret 환경변수명 — '<APP>_JWT_SECRET' (세션 secret 관례와 대칭).
37
+ * doctor auth-wiring 의 JWT 수리 안내가 같은 이름을 제시해야 해 export 한다(단일 출처).
38
+ */
39
+ export declare function jwtSecretEnvFor(app: string): string;
35
40
  /**
36
41
  * 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
37
42
  * 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
package/dist/generate.js CHANGED
@@ -65,8 +65,11 @@ function devSessionSecretFor(app) {
65
65
  function envSecretPlaceholderFor(app) {
66
66
  return `change-me-to-a-32-char-${app}-session-secret!!`;
67
67
  }
68
- /** 결정 338: 앱별 JWT secret 환경변수명 — '<APP>_JWT_SECRET' (세션 secret 관례와 대칭). */
69
- function jwtSecretEnvFor(app) {
68
+ /**
69
+ * 결정 338: 앱별 JWT secret 환경변수명 — '<APP>_JWT_SECRET' (세션 secret 관례와 대칭).
70
+ * doctor auth-wiring 의 JWT 수리 안내가 같은 이름을 제시해야 해 export 한다(단일 출처).
71
+ */
72
+ export function jwtSecretEnvFor(app) {
70
73
  return `${app.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_JWT_SECRET`;
71
74
  }
72
75
  /** .env(.example)에 시드할 앱별 JWT secret 플레이스홀더(32자 이상 · 운영은 교체 — 결정 337 가드 대상). */
@@ -382,8 +385,13 @@ export function runGenerateAuthCommand(opts = {}) {
382
385
  const app = opts.app ?? 'web';
383
386
  // 결정 338: JWT 는 API 앱 전용(§7 · web 은 세션이 정본). 형식 불량은 즉시 수리 안내.
384
387
  if (opts.jwt && (opts.app === undefined || app === 'web')) {
388
+ // 결정 338: API(JWT) 앱은 이 명령 **하나**가 앱 폴더째 만든다(routes·컨트롤러·app.config).
389
+ // 이전 힌트는 `gaon g app api && gaon g auth --jwt --app api` 를 권했는데, 앞 명령이
390
+ // 만든 app.config.ts·Vue 프론트(pages/layouts) 때문에 뒤 명령이 JWT 배선을 자동 추가하지
391
+ // 못하고 incomplete → exit 1 로 끝났다(결정 361). API 앱에는 쓰지 않는 Vue 페이지까지
392
+ // 남는다 — 권하는 순서가 곧 실패 경로였던 셈이라 단독 실행을 정본으로 못박는다.
385
393
  process.stderr.write(` ✗ gaon g auth --jwt: JWT 는 API 앱 전용입니다 — 대상 API 앱을 --app 으로 지정하세요(web 불가 · 세션이 정본).\n` +
386
- ` → 예: gaon g app api && gaon g auth --jwt --app api\n`);
394
+ ` → 예: gaon g auth --jwt --app api (이 명령 하나가 apps/api/ 를 통째로 만듭니다 — gaon g app 먼저 돌리지 마세요)\n`);
387
395
  return 1;
388
396
  }
389
397
  if (opts.jwt && opts.public) {
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import { runHubCommand } from "./hub.js";
27
27
  import { runWorkCommand } from "./work.js";
28
28
  import { runJobsCommand } from "./jobs.js";
29
29
  import { runDbCommand } from "./commands/db.js";
30
- import { runDoctorCommand, ALL_RULES } from "./doctor.js";
30
+ import { runDoctorCommand, ALL_RULES, RULE_SUMMARIES } from "./doctor.js";
31
31
  import { runMcpCommand } from "./commands/mcp.js";
32
32
  export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
33
33
  export { runDevCommand } from "./commands/dev.js";
@@ -111,7 +111,9 @@ function renderHelp(version = VERSION) {
111
111
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
112
112
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all)",
113
113
  " gaon test -- <인자> `--` 뒤는 vitest 로 그대로 전달 (gaon 이 안 가로챔 · 예: gaon test -- --json --reporter=json)",
114
- ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지·render return 누락)`,
114
+ // 열거는 RULE_SUMMARIES(doctor.ts) 단일 출처로 쓴다 손유지 산문이 뒤처져
115
+ // 새 검사가 help 에서 빠지던 표류 방지(타입이 전수 채움을 강제).
116
+ ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · ${ALL_RULES.map((r) => RULE_SUMMARIES[r]).join("·")})`,
115
117
  " gaon doctor --json 자동화용 JSON 출력",
116
118
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
117
119
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -149,7 +151,7 @@ function renderHelp(version = VERSION) {
149
151
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
150
152
  */
151
153
  export function parseDoctorChecks(argv) {
152
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 28종)를 단일 출처로 쓴다 — 과거
154
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 29종)를 단일 출처로 쓴다 — 과거
153
155
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
154
156
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
155
157
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -170,7 +172,7 @@ export function parseDoctorChecks(argv) {
170
172
  }
171
173
  // 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
172
174
  // 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
173
- // 28종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
175
+ // 29종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
174
176
  if (unknown.length > 0) {
175
177
  process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
176
178
  ` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
@@ -470,7 +472,7 @@ export function runCli(argv, opts = {}) {
470
472
  });
471
473
  return;
472
474
  }
473
- // `gaon doctor` — 정적 검사(M9-E · 28 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
475
+ // `gaon doctor` — 정적 검사(M9-E · 29 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
474
476
  // 선택 실행, --json 은 자동화 파싱용.
475
477
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
476
478
  if (argv[0] === "doctor") {
@@ -18,6 +18,8 @@
18
18
  * @param app 대상 앱 폴더(apps/<app>/).
19
19
  */
20
20
  export function controllerScaffold(names, app) {
21
+ // 라우트 키·api() 키는 **앱 접두**를 포함한다(결정 55 · 'app:controller#action') —
22
+ // 접두 없는 'posts#count' 는 멀티앱에서 해상되지 않아 주석 그대로 복사하면 컴파일이 깨진다.
21
23
  const { pascal, plural } = names;
22
24
  const lines = [
23
25
  `// ${plural} 컨트롤러 — gaon g controller (M9-B).`,
@@ -35,7 +37,7 @@ export function controllerScaffold(names, app) {
35
37
  ` },`,
36
38
  ``,
37
39
  ` // GET /${plural}/count.json — JSON 액션 (errata E-3).`,
38
- ` // 반환값 = 응답. api('${plural}#count') 클라이언트가 Serialized<> 로 받는다.`,
40
+ ` // 반환값 = 응답. api('${app}:${plural}#count') 클라이언트가 Serialized<> 로 받는다.`,
39
41
  ` // this.params() 안전 규칙: 라우트 > body > query (errata E-3 §5.1).`,
40
42
  ` async count() {`,
41
43
  ` return { total: await ${pascal}.count() }`,
@@ -22,10 +22,12 @@ export function pageScaffold(pagePath, app) {
22
22
  const segments = trimmed.split('/').filter(Boolean);
23
23
  const last = segments[segments.length - 1];
24
24
  const parent = segments[segments.length - 2] ?? last;
25
- // 라우트 키 기본값 — 앱 접두(결정 55) + 폴더=리소스명(복수·소문자) +
26
- // 파일=액션명(소문자). 앱 네임스페이스가 있어야 멀티앱에서 전역 GaonRouteMap
27
- // 충돌이 없다.
28
- const routeKey = `${app}:${toCamel(parent).toLowerCase()}#${toCamel(last).toLowerCase()}`;
25
+ // 라우트 키 기본값 — 앱 접두(결정 55) + 폴더=리소스명(컨트롤러 파일명) +
26
+ // 파일=액션명. 앱 네임스페이스가 있어야 멀티앱에서 전역 GaonRouteMap 충돌이 없다.
27
+ // camelCase 를 **평탄화하지 않는다**: 컨트롤러 파일명·액션명 관례가 camelCase 라
28
+ // (`blogPosts.ts` · `editForm`) 소문자로 뭉개면 `blogposts#editform` 처럼 존재하지
29
+ // 않는 키가 나와 pageProps 가 해상되지 않는다(gaon g page BlogPosts/EditForm 실측).
30
+ const routeKey = `${app}:${toCamel(parent)}#${toCamel(last)}`;
29
31
  const filePath = `apps/${app}/pages/${trimmed}.vue`;
30
32
  const lines = [
31
33
  `<script setup lang="ts">`,
@@ -58,9 +58,11 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
58
58
  1. **TypeScript 전용 · 함수/객체 스타일.** JS 파일 추가 금지, 클래스형·
59
59
  데코레이터 금지 — `model()`·`controller()`·`job()`·`service()`·
60
60
  `channel()` 함수형 API 만.
61
- 2. **파사드 import 만.** 프레임웍 심볼은 `gaonjs/*` (`gaonjs/web`·
62
- `gaonjs/data`·`gaonjs/vue`·`gaonjs/async`·`gaonjs/service
63
- `gaonjs/testing`)에서 import 한다. `@gaonjs/*`(내부 스코프)·
61
+ 2. **파사드 import 만.** 프레임웍 심볼은 `gaonjs` 와 그 하위 경로에서
62
+ import 한다 — 전체 목록: `gaonjs`·`gaonjs/data`·`gaonjs/web
63
+ `gaonjs/vue`·`gaonjs/async`·`gaonjs/config`·`gaonjs/service`·
64
+ `gaonjs/mail`·`gaonjs/storage`·`gaonjs/i18n`·`gaonjs/env`·
65
+ `gaonjs/log`·`gaonjs/testing`. `@gaonjs/*`(내부 스코프)·
64
66
  `@inertiajs/vue3`(어댑터 내부 의존)는 앱 코드에서 직접 import 금지.
65
67
  (설치명 `gaonjs` · CLI 명령 `gaon` — errata E-1.)
66
68
  3. **의존 방향 4규칙** (doctor 강제): ① 앱→`domain/` 허용 ②
@@ -111,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
111
113
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
112
114
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
113
115
 
114
- ### 2.2 `gaon doctor` 검사 28
116
+ ### 2.2 `gaon doctor` 검사 29
115
117
 
116
118
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
117
119
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -141,6 +143,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
141
143
  26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
142
144
  27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
143
145
  28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
146
+ 29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), 일부러 공유하는 채널이면 정의를 `shared/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나) — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (`agents/realtime.md` §2)
144
147
 
145
148
  ## 3. 로직 배치 One Way 판단표
146
149
 
@@ -201,7 +204,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
201
204
  ```bash
202
205
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
203
206
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
204
- gaon doctor # 정적 검사 28종 (§2.2)
207
+ gaon doctor # 정적 검사 29종 (§2.2)
205
208
  ```
206
209
 
207
210
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -212,6 +215,7 @@ gaon doctor # 정적 검사 28종 (§2.2)
212
215
  | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
213
216
  | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
214
217
  | `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) |
218
+ | `gaon g auth --jwt --app <앱>` | **API(JWT) 앱 변형** — 이 명령 **하나**가 앱 폴더째 만든다(토큰 컨트롤러 3종 + `strategy:'jwt'` app.config + `<APP>_JWT_SECRET` 시드 · 페이지·UI 킷·회원가입 없음). `gaon g app` 을 먼저 돌리지 않는다 — 이미 있는 app.config 는 자동 배선을 못 해 exit 1 이고, 쓰지 않는 Vue 프론트가 남는다. web 앱은 세션이 정본이라 `--jwt` 불가 (결정 337·338) |
215
219
  | `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
216
220
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
217
221
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -86,7 +86,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
86
86
 
87
87
  ```bash
88
88
  gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
89
- gaon doctor # 정적 검사 28종 (상세 AGENTS §2.2)
89
+ gaon doctor # 정적 검사 29종 (상세 AGENTS §2.2)
90
90
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
91
91
  ```
92
92