@gaonjs/cli 0.47.0 → 0.52.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 (49) hide show
  1. package/dist/commands/check.d.ts +1 -1
  2. package/dist/commands/check.js +1 -1
  3. package/dist/commands/test.js +16 -3
  4. package/dist/db/journal.d.ts +4 -3
  5. package/dist/db/journal.js +21 -10
  6. package/dist/db/migrate.d.ts +3 -1
  7. package/dist/db/migrate.js +3 -3
  8. package/dist/db/replay.js +2 -2
  9. package/dist/db/status.js +13 -3
  10. package/dist/dev.d.ts +6 -4
  11. package/dist/dev.js +9 -4
  12. package/dist/doctor/fixers/index.d.ts +1 -1
  13. package/dist/doctor/fixers/index.js +6 -1
  14. package/dist/doctor/locale-parity.js +4 -1
  15. package/dist/doctor/render-return.d.ts +11 -0
  16. package/dist/doctor/render-return.js +143 -0
  17. package/dist/doctor/types.d.ts +1 -1
  18. package/dist/doctor.d.ts +3 -2
  19. package/dist/doctor.js +16 -5
  20. package/dist/generate.d.ts +20 -1
  21. package/dist/generate.js +120 -21
  22. package/dist/hub.js +2 -0
  23. package/dist/i18n-config.d.ts +12 -0
  24. package/dist/i18n-config.js +95 -0
  25. package/dist/index.js +41 -12
  26. package/dist/mcp/tools.d.ts +1 -1
  27. package/dist/mcp/tools.js +9 -6
  28. package/dist/messages-gen.d.ts +1 -1
  29. package/dist/messages-gen.js +5 -2
  30. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  31. package/dist/templates/auth/Login.vue.tpl +3 -5
  32. package/dist/templates/auth/Signup.vue.tpl +3 -5
  33. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  34. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  35. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  36. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  37. package/dist/templates/project/AGENTS.md.tpl +3 -2
  38. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  39. package/dist/templates/project/agents/async.md.tpl +35 -8
  40. package/dist/templates/project/agents/data.md.tpl +113 -49
  41. package/dist/templates/project/agents/frontend.md.tpl +15 -4
  42. package/dist/templates/project/agents/i18n.md.tpl +5 -2
  43. package/dist/templates/project/agents/mail.md.tpl +2 -1
  44. package/dist/templates/project/agents/realtime.md.tpl +16 -6
  45. package/dist/templates/project/agents/seal.md.tpl +6 -3
  46. package/dist/templates/project/agents/security.md.tpl +37 -19
  47. package/dist/templates/project/agents/storage.md.tpl +6 -5
  48. package/dist/templates/project/agents/web.md.tpl +40 -22
  49. package/package.json +6 -6
@@ -52,7 +52,7 @@ export interface CheckReport {
52
52
  /**
53
53
  * `gaon check` 진입점. 검사 전에 .gaon 을 재생성(규칙 3)한 뒤 각 단계를
54
54
  * 순서대로 실행하고, 하나라도 실패하면 exit 1. --only 지정 시 그 단계만
55
- * 실행. --include-doctor doctor 추가.
55
+ * 실행. doctor 는 기본 포함(결정 157) — --no-doctor 로만 뺀다.
56
56
  *
57
57
  * §9 실 인프라 · 목업 X — 실 spawn 으로 검증한다.
58
58
  */
@@ -271,7 +271,7 @@ async function regenerateGaon(cwd) {
271
271
  /**
272
272
  * `gaon check` 진입점. 검사 전에 .gaon 을 재생성(규칙 3)한 뒤 각 단계를
273
273
  * 순서대로 실행하고, 하나라도 실패하면 exit 1. --only 지정 시 그 단계만
274
- * 실행. --include-doctor doctor 추가.
274
+ * 실행. doctor 는 기본 포함(결정 157) — --no-doctor 로만 뺀다.
275
275
  *
276
276
  * §9 실 인프라 · 목업 X — 실 spawn 으로 검증한다.
277
277
  */
@@ -106,7 +106,11 @@ async function provisionTestDatabases(cwd, json, onOutput) {
106
106
  connectionOverride: testCfg,
107
107
  });
108
108
  if (res.exitCode !== 0) {
109
- if (!json)
109
+ // 결정 360: json 모드도 구조화 에러를 낸다 — 이전엔 이 분기만 0 바이트로
110
+ // exit 1 이라 자동화가 실패 원인을 전혀 못 받았다(catch 분기와 비대칭).
111
+ if (json)
112
+ writeOut(JSON.stringify({ ok: false, kind: 'provision', db: key, error: res.text }) + '\n');
113
+ else
110
114
  writeErr(` ✗ 테스트 DB '${key}' 마이그레이션 실패\n${res.text}\n`);
111
115
  return false;
112
116
  }
@@ -211,18 +215,27 @@ export async function runTestCommand(args = [], opts = {}) {
211
215
  else {
212
216
  writeOut(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
213
217
  }
218
+ // 결정 360: --json 은 stdout 이 순수 JSON 라인이어야 한다(결정 269 check 와 대칭).
219
+ // 이전엔 json 모드도 stdio:'inherit' 라 vitest 사람용 출력이 JSON 라인 사이에 섞여
220
+ // `gaon test --json | jq` 류 자동화가 깨졌다. 자식 출력은 {kind:'output'} 라인으로 감싼다.
221
+ const jsonPipe = json && !capture;
214
222
  const exitCode = await new Promise((resolvePromise) => {
215
223
  // capture(MCP) 모드는 자식 출력을 파이프로 모아 싱크로 흘린다(도구 응답에 담기).
216
- // CLI 경로(미지정)는 vitest 컬러 출력·리포터를 그대로 보이게 stdio 를 상속한다.
224
+ // CLI 사람 경로(미지정)는 vitest 컬러 출력·리포터를 그대로 보이게 stdio 를 상속한다.
217
225
  const child = spawn(cmd, spawnArgs, {
218
226
  cwd,
219
227
  env: testEnv,
220
- stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
228
+ stdio: capture || jsonPipe ? ['ignore', 'pipe', 'pipe'] : 'inherit',
221
229
  });
222
230
  if (capture) {
223
231
  child.stdout?.on('data', (d) => opts.onOutput(d.toString('utf8')));
224
232
  child.stderr?.on('data', (d) => opts.onOutput(d.toString('utf8')));
225
233
  }
234
+ else if (jsonPipe) {
235
+ const emit = (stream) => (d) => writeOut(JSON.stringify({ kind: 'output', stream, chunk: d.toString('utf8') }) + '\n');
236
+ child.stdout?.on('data', emit('stdout'));
237
+ child.stderr?.on('data', emit('stderr'));
238
+ }
226
239
  child.on('error', (err) => {
227
240
  writeErr(` ✗ gaon test spawn 실패: ${String(err)}\n`);
228
241
  resolvePromise(127);
@@ -1,4 +1,5 @@
1
1
  import { type Kysely } from 'kysely';
2
+ import { type AdapterName } from '@gaonjs/data';
2
3
  export type MigrationKind = 'file' | 'schema';
3
4
  export interface JournalEntry {
4
5
  readonly id: string;
@@ -17,7 +18,7 @@ export interface JournalEntry {
17
18
  * pg 는 count 를 bigint(문자열)로, mysql 은 number 로 돌려주므로 `Number()` 로 통일한다
18
19
  * (양 방언 공통 · 결정 268).
19
20
  */
20
- export declare function journalExists(db: Kysely<any>): Promise<boolean>;
21
+ export declare function journalExists(db: Kysely<any>, adapter?: AdapterName): Promise<boolean>;
21
22
  /**
22
23
  * _gaon_migrations 를 만든다(존재하면 no-op). id/kind/applied_at/db_key/
23
24
  * statements/summary/down_sql — text·varchar·timestamp·integer 는 postgres·
@@ -35,7 +36,7 @@ export interface RecordArgs {
35
36
  /** 이력 한 행을 남긴다. 호출자가 트랜잭션(trx)을 넘기면 그 안에서 실행된다. */
36
37
  export declare function recordEntry(db: Kysely<any>, e: RecordArgs): Promise<void>;
37
38
  /** 가장 최근 적용된 이력(롤백 대상). 원장 미존재 시 undefined. */
38
- export declare function latestEntry(db: Kysely<any>): Promise<JournalEntry | undefined>;
39
+ export declare function latestEntry(db: Kysely<any>, adapter?: AdapterName): Promise<JournalEntry | undefined>;
39
40
  /** kind='file' 로 적용된 파일명 집합. 원장 미존재 시 빈 집합. */
40
- export declare function appliedFileMigrations(db: Kysely<any>): Promise<Set<string>>;
41
+ export declare function appliedFileMigrations(db: Kysely<any>, adapter?: AdapterName): Promise<Set<string>>;
41
42
  export declare function deleteEntry(db: Kysely<any>, id: string): Promise<void>;
@@ -17,12 +17,23 @@ import { MIGRATIONS_TABLE } from '@gaonjs/data';
17
17
  * pg 는 count 를 bigint(문자열)로, mysql 은 number 로 돌려주므로 `Number()` 로 통일한다
18
18
  * (양 방언 공통 · 결정 268).
19
19
  */
20
- export async function journalExists(db) {
21
- const rows = await sql `
22
- select count(*) as n
23
- from information_schema.tables
24
- where table_name = ${MIGRATIONS_TABLE}
25
- `.execute(db);
20
+ export async function journalExists(db, adapter = 'postgres') {
21
+ // 결정 326: mysql/mariadb information_schema.tables 는 **서버 전체(모든 database)** 를
22
+ // 보므로 같은 서버 다른 DB 의 _gaon_migrations 에 오탐했다 — 존재 오판 뒤
23
+ // appliedFileMigrations 가 없는 테이블을 select 해 migrate/status 가 죽는다(공유 dev 서버
24
+ // 현실 시나리오) → database() 로 현재 DB 에 한정한다. pg 는 카탈로그가 database 단위라
25
+ // 종전대로 둔다(스키마('public') 한정은 커스텀 search_path 프로젝트를 깨서 안 한다).
26
+ const rows = adapter === 'mysql'
27
+ ? await sql `
28
+ select count(*) as n
29
+ from information_schema.tables
30
+ where table_name = ${MIGRATIONS_TABLE} and table_schema = database()
31
+ `.execute(db)
32
+ : await sql `
33
+ select count(*) as n
34
+ from information_schema.tables
35
+ where table_name = ${MIGRATIONS_TABLE}
36
+ `.execute(db);
26
37
  const first = rows.rows[0];
27
38
  return first ? Number(first.n) > 0 : false;
28
39
  }
@@ -53,8 +64,8 @@ export async function recordEntry(db, e) {
53
64
  `.execute(db);
54
65
  }
55
66
  /** 가장 최근 적용된 이력(롤백 대상). 원장 미존재 시 undefined. */
56
- export async function latestEntry(db) {
57
- if (!(await journalExists(db)))
67
+ export async function latestEntry(db, adapter = 'postgres') {
68
+ if (!(await journalExists(db, adapter)))
58
69
  return undefined;
59
70
  const rows = await sql `
60
71
  select id, kind, db_key, statements, summary, down_sql, applied_at
@@ -65,8 +76,8 @@ export async function latestEntry(db) {
65
76
  return rows.rows[0];
66
77
  }
67
78
  /** kind='file' 로 적용된 파일명 집합. 원장 미존재 시 빈 집합. */
68
- export async function appliedFileMigrations(db) {
69
- if (!(await journalExists(db)))
79
+ export async function appliedFileMigrations(db, adapter = 'postgres') {
80
+ if (!(await journalExists(db, adapter)))
70
81
  return new Set();
71
82
  const rows = await sql `
72
83
  select id from ${sql.ref(MIGRATIONS_TABLE)} where kind = 'file'
@@ -1,4 +1,4 @@
1
- import { type ConnectionConfig } from '@gaonjs/data';
1
+ import { type MigrationOp, type ConnectionConfig } from '@gaonjs/data';
2
2
  import { listMigrationFiles } from './replay.js';
3
3
  export interface DbMigrateOptions {
4
4
  readonly cwd: string;
@@ -16,6 +16,8 @@ export interface DbMigrateResult {
16
16
  readonly text: string;
17
17
  readonly json: unknown;
18
18
  }
19
+ /** drop 계열 부속 op 을 사람이 읽는 한 줄로 — 크게 알리는 note 용(status 도 재사용 · 결정 326). */
20
+ export declare function describeDeferred(op: MigrationOp): string;
19
21
  /**
20
22
  * `gaon db migrate` — 진짜 apply(합성형: replay → schema-diff). 실 DB 필요(§9).
21
23
  */
@@ -17,8 +17,8 @@ import { computeMigration, renderUp, renderDown, summarizeOp, DEFERRED_DROP_KIND
17
17
  import { resolveDbTarget } from './resolve.js';
18
18
  import { ensureJournal, journalExists, recordEntry } from './journal.js';
19
19
  import { listMigrationFiles, replayPending, rollbackLast } from './replay.js';
20
- /** drop 계열 부속 op 을 사람이 읽는 한 줄로 — 크게 알리는 note 용. */
21
- function describeDeferred(op) {
20
+ /** drop 계열 부속 op 을 사람이 읽는 한 줄로 — 크게 알리는 note 용(status 도 재사용 · 결정 326). */
21
+ export function describeDeferred(op) {
22
22
  switch (op.kind) {
23
23
  case 'dropUnique':
24
24
  return `unique(${op.name}: ${op.columns.join(', ')})`;
@@ -116,7 +116,7 @@ export async function runDbMigrate(opts) {
116
116
  };
117
117
  }
118
118
  // baseline 감지 — journal 이 없는데 createTable 이 포함 → 안내(§7.5.3).
119
- const hadJournal = await journalExists(target.db);
119
+ const hadJournal = await journalExists(target.db, target.adapter);
120
120
  const hasCreate = plan.ops.some((o) => o.kind === 'createTable');
121
121
  const baselineNote = !hadJournal && hasCreate
122
122
  ? ` ℹ 최초 마이그레이션(_gaon_migrations 미존재) — 이 배치가 baseline 이 됩니다.\n` +
package/dist/db/replay.js CHANGED
@@ -66,7 +66,7 @@ export async function replayPending(opts) {
66
66
  const all = listMigrationFiles(opts.cwd);
67
67
  if (all.length === 0)
68
68
  return { applied: [], pending: [], all: [] };
69
- const done = await appliedFileMigrations(opts.db);
69
+ const done = await appliedFileMigrations(opts.db, opts.adapter);
70
70
  const pending = all.filter((f) => !done.has(f));
71
71
  if (opts.dryRun || pending.length === 0) {
72
72
  return { applied: [], pending, all };
@@ -104,7 +104,7 @@ export async function replayPending(opts) {
104
104
  * 되돌린 뒤 이력 행을 지운다. --dry-run 은 대상만 보고 실행하지 않는다.
105
105
  */
106
106
  export async function rollbackLast(opts) {
107
- const entry = await latestEntry(opts.db);
107
+ const entry = await latestEntry(opts.db, opts.adapter);
108
108
  if (!entry)
109
109
  return { rolledBack: null, kind: null };
110
110
  if (opts.dryRun)
package/dist/db/status.js CHANGED
@@ -2,9 +2,10 @@
2
2
  //
3
3
  // db/migrations/*.ts 각각의 적용/미적용과, 스키마↔DB 사이에 아직 반영 안 된
4
4
  // 변경(schema drift) 수를 함께 보여준다. 아무것도 실행하지 않는다.
5
- import { computeMigration } from '@gaonjs/data';
5
+ import { computeMigration, summarizeOp, DEFERRED_DROP_KINDS } from '@gaonjs/data';
6
6
  import { resolveDbTarget } from './resolve.js';
7
7
  import { appliedFileMigrations } from './journal.js';
8
+ import { describeDeferred } from './migrate.js';
8
9
  import { listMigrationFiles } from './replay.js';
9
10
  export async function runDbStatus(opts) {
10
11
  const target = await resolveDbTarget({
@@ -14,7 +15,7 @@ export async function runDbStatus(opts) {
14
15
  });
15
16
  try {
16
17
  const files = listMigrationFiles(opts.cwd);
17
- const done = await appliedFileMigrations(target.db);
18
+ const done = await appliedFileMigrations(target.db, target.adapter);
18
19
  const migrations = files.map((f) => ({ file: `db/migrations/${f}`, applied: done.has(f) }));
19
20
  const pendingFiles = migrations.filter((m) => !m.applied).length;
20
21
  // migrate 는 스키마에 없는 테이블을 자동 DROP 하지 않으므로(결정 39 · §4.8)
@@ -23,7 +24,11 @@ export async function runDbStatus(opts) {
23
24
  const extraTables = plan.ops
24
25
  .filter((o) => o.kind === 'dropTable')
25
26
  .map((o) => o.name);
26
- const schemaChanges = plan.ops.length - extraTables.length;
27
+ // 결정 326: deferred aux drop(제약·인덱스·기본값 제거)도 migrate 가 자동 적용하지
28
+ // 않는다(결정 220) — drift 로 세면 "'gaon db migrate' 로 반영하세요" 가 영원히 안
29
+ // 지워지는 거짓 안내가 된다. dropTable 과 같은 축의 참고 항목으로 분리한다.
30
+ const deferredDrops = plan.ops.filter((o) => o.kind !== 'dropTable' && DEFERRED_DROP_KINDS.has(o.kind));
31
+ const schemaChanges = plan.ops.length - extraTables.length - deferredDrops.length;
27
32
  const lines = [];
28
33
  if (migrations.length === 0) {
29
34
  lines.push(` [${opts.dbKey}] 마이그레이션 파일 없음 (db/migrations/).`);
@@ -42,6 +47,10 @@ export async function runDbStatus(opts) {
42
47
  if (extraTables.length > 0) {
43
48
  lines.push(` 참고: 스키마에 없는 테이블 ${extraTables.length}개(자동 DROP 안 함): ${extraTables.join(', ')}`);
44
49
  }
50
+ if (deferredDrops.length > 0) {
51
+ lines.push(` 참고: 스키마에서 사라진 제약·인덱스·기본값 ${deferredDrops.length}개(자동 제거 안 함 — ` +
52
+ `'gaon db diff' 의 down SQL 로 손작성 마이그): ${deferredDrops.map(describeDeferred).join(', ')}`);
53
+ }
45
54
  return {
46
55
  exitCode: 0,
47
56
  text: lines.join('\n'),
@@ -52,6 +61,7 @@ export async function runDbStatus(opts) {
52
61
  pendingFiles,
53
62
  schemaChanges,
54
63
  extraTables,
64
+ deferredDrops: deferredDrops.map(summarizeOp),
55
65
  },
56
66
  };
57
67
  }
package/dist/dev.d.ts CHANGED
@@ -13,6 +13,8 @@ export interface DevLayout {
13
13
  readonly apps: readonly DevApp[];
14
14
  /** locales/ (없으면 undefined — messages 축 생략 · 결정 158 · W2). */
15
15
  readonly localesDir?: string;
16
+ /** 기준 로케일 = config i18n.fallbackLng (정적 분석 · 결정 352 · 못 읽으면 undefined). */
17
+ readonly messagesBaseLng?: string;
16
18
  /** .gaon/messages.d.ts */
17
19
  readonly messagesOut: string;
18
20
  /** 프론트 진입(index.html)이 있는 앱이 하나라도 있는가 — env 축 적용 조건(결정 198). */
@@ -26,8 +28,8 @@ export interface DevDeps {
26
28
  readonly layout: DevLayout;
27
29
  regenerateTables(schemaDir: string, out: string): Promise<unknown>;
28
30
  regenerateRoutes(appDir: string, out: string): Promise<unknown>;
29
- /** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
30
- regenerateMessages?(localesDir: string, out: string): unknown;
31
+ /** locales/ → .gaon/messages.d.ts (결정 158 · W2 · 기준 로케일 = 결정 352). i18n 축을 쓰는 호출자만 준다. */
32
+ regenerateMessages?(localesDir: string, out: string, baseLng?: string): unknown;
31
33
  /** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
32
34
  regenerateEnv?(envFile: string, out: string): unknown;
33
35
  watch(dir: string, opts: WatchOptions): WatchHandle;
@@ -58,8 +60,8 @@ export declare function startDev(deps: DevDeps): Promise<DevHandle>;
58
60
  export interface RegenDeps {
59
61
  regenerateTables(schemaDir: string, out: string): Promise<unknown>;
60
62
  regenerateRoutes(appDir: string, out: string): Promise<unknown>;
61
- /** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
62
- regenerateMessages?(localesDir: string, out: string): unknown;
63
+ /** locales/ → .gaon/messages.d.ts (결정 158 · W2 · 기준 로케일 = 결정 352). i18n 축을 쓰는 호출자만 준다. */
64
+ regenerateMessages?(localesDir: string, out: string, baseLng?: string): unknown;
63
65
  /** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
64
66
  regenerateEnv?(envFile: string, out: string): unknown;
65
67
  }
package/dist/dev.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { readdirSync, existsSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
+ import { analyzeProjectI18n } from './i18n-config.js';
16
17
  /** locales/*.json 변경(메시지 카탈로그) — messages.d.ts 재생성 트리거. */
17
18
  const isMessagesChange = (f) => !f.includes('.gaon') && f.endsWith('.json');
18
19
  const isSchemaChange = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && !f.includes('.gaon');
@@ -39,7 +40,7 @@ export async function startDev(deps) {
39
40
  }
40
41
  // 결정 158(W2): locales/ → messages.d.ts.
41
42
  if (layout.localesDir && deps.regenerateMessages) {
42
- await deps.regenerateMessages(layout.localesDir, layout.messagesOut);
43
+ await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng);
43
44
  deps.log({ kind: 'regen', target: 'messages' });
44
45
  }
45
46
  // 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts(VITE_* 타입 브리지). `.env` 부재는
@@ -79,7 +80,7 @@ export async function startDev(deps) {
79
80
  handles.push(deps.watch(localesDir, {
80
81
  filter: isMessagesChange,
81
82
  onChange: async () => {
82
- await regenerateMessages(localesDir, layout.messagesOut);
83
+ await regenerateMessages(localesDir, layout.messagesOut, layout.messagesBaseLng);
83
84
  deps.log({ kind: 'regen', target: 'messages' });
84
85
  },
85
86
  onError: deps.onError,
@@ -110,7 +111,7 @@ export async function regenerateGaonOnce(layout, deps) {
110
111
  // 결정 158(W2): locales/ 가 있으면 messages.d.ts 도 재생성한다(i18n 키 타입 브리지).
111
112
  let messages = false;
112
113
  if (layout.localesDir && deps.regenerateMessages) {
113
- messages = (await deps.regenerateMessages(layout.localesDir, layout.messagesOut)) === true;
114
+ messages = (await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng)) === true;
114
115
  }
115
116
  // 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts. `.env` 부재는 throw(수리 안내).
116
117
  let env = false;
@@ -144,12 +145,16 @@ export function resolveDevLayout(cwd) {
144
145
  });
145
146
  }
146
147
  }
147
- const localesDirPath = join(root, 'locales');
148
+ // 결정 352: 카탈로그 위치·기준 로케일은 gaon.config.ts 의 i18n 블록을 따른다
149
+ // (정적 분석 · 하드코딩 'locales' 는 dir 커스텀 프로젝트에서 타입 축을 무소음으로 껐다).
150
+ const i18nCfg = analyzeProjectI18n(root);
151
+ const localesDirPath = join(root, i18nCfg.dir ?? 'locales');
148
152
  return {
149
153
  schemaDir: existsSync(schemaDirPath) ? schemaDirPath : undefined,
150
154
  tablesOut: join(root, '.gaon', 'tables.d.ts'),
151
155
  apps: apps.sort((a, b) => a.name.localeCompare(b.name)),
152
156
  localesDir: existsSync(localesDirPath) ? localesDirPath : undefined,
157
+ messagesBaseLng: i18nCfg.fallbackLng,
153
158
  messagesOut: join(root, '.gaon', 'messages.d.ts'),
154
159
  hasFrontendApps,
155
160
  envFile: join(root, '.env'),
@@ -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` 27종을 **빠짐없이** 담는다 —
17
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 28종을 **빠짐없이** 담는다 —
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` 27종을 **빠짐없이** 담는다 —
31
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 28종을 **빠짐없이** 담는다 —
32
32
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
33
33
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
34
34
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -165,6 +165,11 @@ export const FIXER_CAPABILITIES = [
165
165
  hasFixer: false,
166
166
  note: '수동 · .vue 의 import.meta.env(TS1470)는 env 접근자로 전환하세요 — 접근자 도입은 코드 편집이 필요합니다(결정 198).',
167
167
  },
168
+ {
169
+ rule: 'render-return',
170
+ hasFixer: false,
171
+ note: '수동 · this.render/redirect/json 호출 앞에 return 을 붙이세요 — 반환값이 곧 응답입니다(결정 340 · 의도된 204 는 응답 호출 없이 return).',
172
+ },
168
173
  {
169
174
  rule: 'locale-parity',
170
175
  hasFixer: false,
@@ -11,6 +11,7 @@
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join, relative } from 'node:path';
13
13
  import { loadLocales, flattenKeys } from '@gaonjs/i18n';
14
+ import { analyzeProjectI18n } from '../i18n-config.js';
14
15
  // 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
15
16
  const MAX_KEYS_SHOWN = 20;
16
17
  // i18next 복수형 접미사(결정 181 · generator.ts 와 동일 규약). 로케일마다 필요한 접미사만
@@ -26,7 +27,9 @@ function pluralBase(key) {
26
27
  * 비교 대상이 없어 건너뛴다(i18n 미사용·단일 로케일 프로젝트는 무소음).
27
28
  */
28
29
  export async function checkLocaleParity(cwd) {
29
- const localesDir = join(cwd, 'locales');
30
+ // 결정 352: 카탈로그 위치는 config i18n.dir 을 따른다(정적 분석 · 하드코딩이던
31
+ // 'locales' 는 dir 커스텀 프로젝트에서 이 검사를 무소음으로 껐다).
32
+ const localesDir = join(cwd, analyzeProjectI18n(cwd).dir ?? 'locales');
30
33
  if (!existsSync(localesDir))
31
34
  return { rule: 'locale-parity', issues: [] };
32
35
  const resources = loadLocales(localesDir);
@@ -0,0 +1,11 @@
1
+ import type { RuleReport } from './types.js';
2
+ export interface DiscardedResponse {
3
+ readonly file: string;
4
+ readonly action: string;
5
+ readonly method: string;
6
+ readonly line: number;
7
+ }
8
+ /** 소스 문자열 하나에서 "버려진 응답 호출" 을 수집한다(단위 테스트 진입점). */
9
+ export declare function inspectDiscardedResponses(file: string, source: string): DiscardedResponse[];
10
+ /** apps/ 를 훑어 버려진 응답 호출(무신호 204)을 모두 낸다(경고 등급). */
11
+ export declare function checkRenderReturn(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,143 @@
1
+ // @gaonjs/cli · doctor · render-return 검사 (결정 340)
2
+ //
3
+ // 액션 본문이 `this.render(...)` / `this.redirect(...)` / `this.json(...)` 을
4
+ // **호출만 하고 return 하지 않으면** 반환값(=응답)이 버려져 액션이 void 로 끝나고,
5
+ // 디스패처는 조용히 204 No Content 를 낸다 — 컴파일은 통과하고 페이지는 백지다
6
+ // (무신호 파손 클래스 · agents/web.md 함정 "return 누락 = 204").
7
+ //
8
+ // 정적 분석: 컨트롤러 액션 안에서 위 3종 호출이 ExpressionStatement(또는
9
+ // `await` 만 감싼 문장)로 서 있으면 경고. return · 변수 대입 · 인자 전달 등
10
+ // 값이 소비되는 위치는 오탐 방지로 제외한다. notFound/requireAuth 는 throw
11
+ // 기반 가드라 대상이 아니다(response-mixing 의 GUARD_METHODS 와 정합).
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join, relative } from 'node:path';
14
+ import ts from 'typescript';
15
+ const RESPONSE_METHODS = new Set(['render', 'redirect', 'json']);
16
+ /** 소스 문자열 하나에서 "버려진 응답 호출" 을 수집한다(단위 테스트 진입점). */
17
+ export function inspectDiscardedResponses(file, source) {
18
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
19
+ const found = [];
20
+ const visit = (node) => {
21
+ if (ts.isCallExpression(node) && isControllerCall(node)) {
22
+ const arg = node.arguments[0];
23
+ if (arg && ts.isObjectLiteralExpression(arg)) {
24
+ for (const prop of arg.properties) {
25
+ const action = actionName(prop);
26
+ const body = actionBody(prop);
27
+ if (!action || !body || !ts.isBlock(body))
28
+ continue;
29
+ collectDiscarded(sf, body, action, file, found);
30
+ }
31
+ }
32
+ }
33
+ ts.forEachChild(node, visit);
34
+ };
35
+ visit(sf);
36
+ return found;
37
+ }
38
+ function isControllerCall(node) {
39
+ const e = node.expression;
40
+ if (ts.isIdentifier(e) && e.text === 'controller')
41
+ return true;
42
+ if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
43
+ return true;
44
+ return false;
45
+ }
46
+ function actionName(prop) {
47
+ if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
48
+ return prop.name.text;
49
+ if (ts.isPropertyAssignment(prop) &&
50
+ ts.isIdentifier(prop.name) &&
51
+ (ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
52
+ return prop.name.text;
53
+ }
54
+ return undefined;
55
+ }
56
+ function actionBody(prop) {
57
+ if (ts.isMethodDeclaration(prop))
58
+ return prop.body;
59
+ if (ts.isPropertyAssignment(prop)) {
60
+ if (ts.isArrowFunction(prop.initializer))
61
+ return prop.initializer.body;
62
+ if (ts.isFunctionExpression(prop.initializer))
63
+ return prop.initializer.body;
64
+ }
65
+ return undefined;
66
+ }
67
+ /** 문장 위치에 버려진 this.render/redirect/json 호출이 있으면 수집한다. */
68
+ function collectDiscarded(sf, body, action, file, out) {
69
+ const visit = (node) => {
70
+ if (ts.isExpressionStatement(node)) {
71
+ const method = responseCallName(node.expression);
72
+ if (method) {
73
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
74
+ out.push({ file, action, method, line: line + 1 });
75
+ return; // 이 문장은 판정 완료 — 내부 재방문 불요.
76
+ }
77
+ }
78
+ // 중첩 함수(콜백) 안의 호출은 반환 의미가 달라 제외(오탐 방지).
79
+ if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))
80
+ return;
81
+ ts.forEachChild(node, visit);
82
+ };
83
+ visit(body);
84
+ }
85
+ /** 식이 (await 를 벗긴) this.render/redirect/json 호출이면 메서드 이름을 돌려준다. */
86
+ function responseCallName(expr) {
87
+ const inner = ts.isAwaitExpression(expr) ? expr.expression : expr;
88
+ if (!ts.isCallExpression(inner))
89
+ return undefined;
90
+ const c = inner.expression;
91
+ if (ts.isPropertyAccessExpression(c) &&
92
+ c.expression.kind === ts.SyntaxKind.ThisKeyword &&
93
+ RESPONSE_METHODS.has(c.name.text)) {
94
+ return c.name.text;
95
+ }
96
+ return undefined;
97
+ }
98
+ /** apps/ 를 훑어 버려진 응답 호출(무신호 204)을 모두 낸다(경고 등급). */
99
+ export async function checkRenderReturn(cwd) {
100
+ const appsDir = join(cwd, 'apps');
101
+ const issues = [];
102
+ for (const app of await safeListDirs(appsDir)) {
103
+ const ctrlDir = join(appsDir, app, 'controllers');
104
+ for (const file of await safeListFiles(ctrlDir)) {
105
+ if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
106
+ continue;
107
+ const full = join(ctrlDir, file);
108
+ const source = await readFile(full, 'utf8');
109
+ for (const d of inspectDiscardedResponses(full, source)) {
110
+ const rel = relative(cwd, full);
111
+ issues.push({
112
+ rule: 'render-return',
113
+ level: 'warning',
114
+ file: rel,
115
+ line: d.line,
116
+ message: `버려진 응답 호출: ${rel}:${d.line} · 액션 '${d.action}' 이 this.${d.method}(...) 를 호출만 하고 return 하지 않습니다.\n` +
117
+ `→ 반환값이 곧 응답입니다(errata E-3) — return 이 없으면 액션이 void 로 끝나 조용히 204 No Content(백지)가 나갑니다.\n` +
118
+ `→ \`return this.${d.method}(...)\` 로 고치세요. (의도된 204 는 아무 응답 호출 없이 그냥 return 합니다.)`,
119
+ detail: { action: d.action, method: d.method },
120
+ });
121
+ }
122
+ }
123
+ }
124
+ return { rule: 'render-return', issues };
125
+ }
126
+ async function safeListDirs(dir) {
127
+ try {
128
+ const entries = await readdir(dir, { withFileTypes: true });
129
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
130
+ }
131
+ catch {
132
+ return [];
133
+ }
134
+ }
135
+ async function safeListFiles(dir) {
136
+ try {
137
+ const entries = await readdir(dir, { withFileTypes: true });
138
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ }
@@ -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';
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';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type DoctorCheck, type DoctorFatal, type DoctorFatalCode, type DoctorLe
2
2
  export type { DoctorCheck, DoctorFatal, DoctorFatalCode, DoctorLevel, DoctorResult, DoctorRule, RuleReport, };
3
3
  export type { ResponseKind, ActionUsage } from './doctor/response-mixing.js';
4
4
  export { inspectControllerSource, checkResponseMixing } from './doctor/response-mixing.js';
5
+ export { inspectDiscardedResponses, checkRenderReturn } from './doctor/render-return.js';
5
6
  export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one.js';
6
7
  export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
7
8
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
@@ -26,10 +27,10 @@ export { checkLocaleParity } from './doctor/locale-parity.js';
26
27
  export { renderHuman, renderJson } from './doctor/reporter.js';
27
28
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
28
29
  /**
29
- * 실행할 검사 이름. 지정 없음(undefined) = 27개 모두.
30
+ * 실행할 검사 이름. 지정 없음(undefined) = 28개 모두.
30
31
  */
31
32
  /**
32
- * doctor 정적 검사 27종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
33
+ * doctor 정적 검사 28종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
33
34
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
34
35
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
35
36
  */