@gaonjs/cli 0.52.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.
- package/dist/commands/check.d.ts +2 -0
- package/dist/commands/check.js +43 -1
- package/dist/commands/db.js +9 -0
- package/dist/commands/gen.d.ts +2 -0
- package/dist/commands/gen.js +3 -1
- package/dist/commands/new.js +13 -0
- package/dist/commands/test.js +28 -4
- package/dist/db/journal.d.ts +4 -1
- package/dist/db/journal.js +37 -4
- package/dist/db/migrate.js +11 -11
- package/dist/db/replay.js +1 -1
- package/dist/db/resolve.d.ts +11 -1
- package/dist/db/resolve.js +24 -2
- package/dist/db/status.js +9 -6
- package/dist/db.js +26 -5
- package/dist/dev.js +2 -2
- package/dist/doctor/auth-wiring.js +5 -2
- package/dist/doctor/channel-collision.d.ts +9 -0
- package/dist/doctor/channel-collision.js +119 -0
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +6 -1
- package/dist/doctor/locale-parity.js +2 -2
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +10 -2
- package/dist/doctor.js +45 -3
- package/dist/generate.d.ts +5 -0
- package/dist/generate.js +11 -3
- package/dist/i18n-config.d.ts +20 -0
- package/dist/i18n-config.js +87 -12
- package/dist/index.js +111 -26
- package/dist/mcp/tools.js +4 -0
- package/dist/messages-gen.js +11 -3
- package/dist/scaffold/controller.js +3 -1
- package/dist/scaffold/page.js +6 -4
- package/dist/templates/project/AGENTS.md.tpl +9 -5
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/Dockerfile.tpl +11 -1
- package/dist/templates/project/agents/async.md.tpl +56 -14
- package/dist/templates/project/agents/data.md.tpl +146 -35
- package/dist/templates/project/agents/frontend.md.tpl +24 -10
- package/dist/templates/project/agents/i18n.md.tpl +32 -4
- package/dist/templates/project/agents/mail.md.tpl +6 -0
- package/dist/templates/project/agents/realtime.md.tpl +115 -16
- package/dist/templates/project/agents/seal.md.tpl +13 -4
- package/dist/templates/project/agents/security.md.tpl +29 -4
- package/dist/templates/project/agents/storage.md.tpl +57 -13
- package/dist/templates/project/agents/testing.md.tpl +58 -0
- package/dist/templates/project/agents/web.md.tpl +171 -15
- package/dist/work.d.ts +3 -0
- package/dist/work.js +4 -0
- package/package.json +7 -7
|
@@ -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`
|
|
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`
|
|
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
|
];
|
|
@@ -11,7 +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
|
+
import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
|
|
15
15
|
// 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
|
|
16
16
|
const MAX_KEYS_SHOWN = 20;
|
|
17
17
|
// i18next 복수형 접미사(결정 181 · generator.ts 와 동일 규약). 로케일마다 필요한 접미사만
|
|
@@ -29,7 +29,7 @@ function pluralBase(key) {
|
|
|
29
29
|
export async function checkLocaleParity(cwd) {
|
|
30
30
|
// 결정 352: 카탈로그 위치는 config i18n.dir 을 따른다(정적 분석 · 하드코딩이던
|
|
31
31
|
// 'locales' 는 dir 커스텀 프로젝트에서 이 검사를 무소음으로 껐다).
|
|
32
|
-
const localesDir =
|
|
32
|
+
const localesDir = resolveLocalesDir(cwd, analyzeProjectI18n(cwd).dir);
|
|
33
33
|
if (!existsSync(localesDir))
|
|
34
34
|
return { rule: 'locale-parity', issues: [] };
|
|
35
35
|
const resources = loadLocales(localesDir);
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-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) =
|
|
31
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 29개 모두.
|
|
31
32
|
*/
|
|
32
33
|
/**
|
|
33
|
-
* doctor 정적 검사
|
|
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
|
-
*
|
|
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) =
|
|
106
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 29개 모두.
|
|
104
107
|
*/
|
|
105
108
|
/**
|
|
106
|
-
* doctor 정적 검사
|
|
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[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/generate.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
69
|
-
|
|
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
|
|
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/i18n-config.d.ts
CHANGED
|
@@ -5,8 +5,28 @@ export interface ConfigI18nAnalysis {
|
|
|
5
5
|
readonly dir?: string;
|
|
6
6
|
/** 정적 리터럴로 읽힌 fallbackLng. 못 읽으면 undefined. */
|
|
7
7
|
readonly fallbackLng?: string;
|
|
8
|
+
/** 정적 리터럴 배열로 읽힌 supportedLngs(결정 413 · check 미러용). 못 읽으면 undefined. */
|
|
9
|
+
readonly supportedLngs?: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* 결정 412: **선언은 있는데 정적으로 못 읽은** 키들(예 `dir: LOCALES_DIR`).
|
|
12
|
+
* 이 경우 런타임은 실값을, 타입 축·doctor 는 폴백('locales'·정렬 첫 로케일)을 써서
|
|
13
|
+
* 두 축이 갈라진다 — 호출자가 경고를 낼 수 있게 표면화한다(무신호 금지).
|
|
14
|
+
*/
|
|
15
|
+
readonly unresolved: readonly ('dir' | 'fallbackLng')[];
|
|
8
16
|
}
|
|
9
17
|
/** gaon.config.ts 소스에서 i18n 블록의 dir·fallbackLng 리터럴을 뽑는다. */
|
|
10
18
|
export declare function analyzeConfigI18n(source: string): ConfigI18nAnalysis;
|
|
11
19
|
/** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
|
|
12
20
|
export declare function analyzeProjectI18n(cwd: string): ConfigI18nAnalysis;
|
|
21
|
+
/**
|
|
22
|
+
* 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
|
|
23
|
+
* wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
|
|
24
|
+
* `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveLocalesDir(cwd: string, dir: string | undefined): string;
|
|
27
|
+
/**
|
|
28
|
+
* 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
|
|
29
|
+
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 카탈로그를 보거나
|
|
30
|
+
* 아예 안 생길 수 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
|
|
31
|
+
*/
|
|
32
|
+
export declare function warnUnresolvedI18n(analysis: ConfigI18nAnalysis, write: (s: string) => void): void;
|
package/dist/i18n-config.js
CHANGED
|
@@ -15,23 +15,64 @@ export function analyzeConfigI18n(source) {
|
|
|
15
15
|
let declared = false;
|
|
16
16
|
let dir;
|
|
17
17
|
let fallbackLng;
|
|
18
|
+
let supportedLngs;
|
|
19
|
+
const unresolved = new Set();
|
|
20
|
+
// 결정 412: `as`·`satisfies` 래핑을 벗긴다 — defineConfig({...} satisfies GaonConfig)
|
|
21
|
+
// 는 실사용 형태인데 종전엔 객체 리터럴이 아니라고 보고 i18n 선언 자체를 놓쳤다.
|
|
22
|
+
const unwrap = (node) => {
|
|
23
|
+
let e = node;
|
|
24
|
+
while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
|
|
25
|
+
e = e.expression;
|
|
26
|
+
}
|
|
27
|
+
return e;
|
|
28
|
+
};
|
|
29
|
+
// 문자열 리터럴 + 치환 없는 템플릿 리터럴(`translations`)을 함께 읽는다 — 개발자
|
|
30
|
+
// 눈에는 똑같은 리터럴인데 종전엔 템플릿만 무소음으로 빠졌다(결정 412).
|
|
31
|
+
const literalText = (e) => {
|
|
32
|
+
const n = unwrap(e);
|
|
33
|
+
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n))
|
|
34
|
+
return n.text;
|
|
35
|
+
return undefined;
|
|
36
|
+
};
|
|
18
37
|
// i18n 초기화식에서 객체 리터럴을 찾는다 — 삼항·??·&&·괄호는 양변을 훑는다
|
|
19
38
|
// (connections.ts 와 동일 규약 · env 조건부 블록 대응). 두 분기가 서로 다른
|
|
20
39
|
// 리터럴을 주는 병리적 케이스는 먼저 읽힌 값을 쓴다(실사용 형태 아님).
|
|
21
|
-
const collect = (
|
|
22
|
-
|
|
23
|
-
return collect(node.expression);
|
|
40
|
+
const collect = (raw) => {
|
|
41
|
+
const node = unwrap(raw);
|
|
24
42
|
if (ts.isObjectLiteralExpression(node)) {
|
|
25
43
|
for (const p of node.properties) {
|
|
44
|
+
// 결정 412: shorthand(`{ i18n }`)·스프레드는 값을 여기서 알 수 없다 — 조용히
|
|
45
|
+
// 넘기지 않고 미해석으로 표시한다.
|
|
46
|
+
if (ts.isShorthandPropertyAssignment(p) || ts.isSpreadAssignment(p)) {
|
|
47
|
+
unresolved.add('dir');
|
|
48
|
+
unresolved.add('fallbackLng');
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
26
51
|
if (!ts.isPropertyAssignment(p))
|
|
27
52
|
continue;
|
|
28
53
|
const name = propNameText(p.name);
|
|
29
|
-
|
|
30
|
-
|
|
54
|
+
// 결정 413: supportedLngs 는 리터럴 배열일 때만 읽는다(check 정적 미러용).
|
|
55
|
+
if (name === 'supportedLngs' && supportedLngs === undefined) {
|
|
56
|
+
const arr = unwrap(p.initializer);
|
|
57
|
+
if (ts.isArrayLiteralExpression(arr)) {
|
|
58
|
+
const items = arr.elements.map((el) => literalText(el));
|
|
59
|
+
if (items.every((v) => v !== undefined))
|
|
60
|
+
supportedLngs = items;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
31
63
|
}
|
|
32
|
-
if (name
|
|
33
|
-
|
|
64
|
+
if (name !== 'dir' && name !== 'fallbackLng')
|
|
65
|
+
continue;
|
|
66
|
+
const text = literalText(p.initializer);
|
|
67
|
+
if (text === undefined) {
|
|
68
|
+
// 변수 참조·env 표현식 등 — 런타임 실값과 갈라지는 지점.
|
|
69
|
+
unresolved.add(name);
|
|
70
|
+
continue;
|
|
34
71
|
}
|
|
72
|
+
if (name === 'dir' && dir === undefined)
|
|
73
|
+
dir = text;
|
|
74
|
+
if (name === 'fallbackLng' && fallbackLng === undefined)
|
|
75
|
+
fallbackLng = text;
|
|
35
76
|
}
|
|
36
77
|
return;
|
|
37
78
|
}
|
|
@@ -53,8 +94,16 @@ export function analyzeConfigI18n(source) {
|
|
|
53
94
|
const visit = (node) => {
|
|
54
95
|
if (ts.isCallExpression(node) && isDefineConfig(node.expression)) {
|
|
55
96
|
const arg = node.arguments[0];
|
|
56
|
-
|
|
57
|
-
|
|
97
|
+
const obj = arg ? unwrap(arg) : undefined;
|
|
98
|
+
if (obj && ts.isObjectLiteralExpression(obj)) {
|
|
99
|
+
for (const p of obj.properties) {
|
|
100
|
+
if (ts.isShorthandPropertyAssignment(p) && p.name.text === 'i18n') {
|
|
101
|
+
// `defineConfig({ i18n })` — 선언은 확실하나 값은 정적으로 못 읽는다.
|
|
102
|
+
declared = true;
|
|
103
|
+
unresolved.add('dir');
|
|
104
|
+
unresolved.add('fallbackLng');
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
58
107
|
if (ts.isPropertyAssignment(p) && propNameText(p.name) === 'i18n') {
|
|
59
108
|
declared = true;
|
|
60
109
|
collect(p.initializer);
|
|
@@ -65,20 +114,46 @@ export function analyzeConfigI18n(source) {
|
|
|
65
114
|
ts.forEachChild(node, visit);
|
|
66
115
|
};
|
|
67
116
|
visit(sf);
|
|
68
|
-
|
|
117
|
+
// 읽힌 키는 미해석 목록에서 뺀다(한 분기만 리터럴인 경우 값이 있으면 그 값을 쓴다).
|
|
118
|
+
if (dir !== undefined)
|
|
119
|
+
unresolved.delete('dir');
|
|
120
|
+
if (fallbackLng !== undefined)
|
|
121
|
+
unresolved.delete('fallbackLng');
|
|
122
|
+
return { declared, dir, fallbackLng, supportedLngs, unresolved: [...unresolved] };
|
|
69
123
|
}
|
|
70
124
|
/** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
|
|
71
125
|
export function analyzeProjectI18n(cwd) {
|
|
72
126
|
const configPath = join(cwd, 'gaon.config.ts');
|
|
73
127
|
if (!existsSync(configPath))
|
|
74
|
-
return { declared: false };
|
|
128
|
+
return { declared: false, unresolved: [] };
|
|
75
129
|
try {
|
|
76
130
|
return analyzeConfigI18n(readFileSync(configPath, 'utf8'));
|
|
77
131
|
}
|
|
78
132
|
catch {
|
|
79
|
-
return { declared: false };
|
|
133
|
+
return { declared: false, unresolved: [] };
|
|
80
134
|
}
|
|
81
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
|
|
138
|
+
* wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
|
|
139
|
+
* `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveLocalesDir(cwd, dir) {
|
|
142
|
+
const d = dir ?? 'locales';
|
|
143
|
+
return d.startsWith('/') ? d : join(cwd, d);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
|
|
147
|
+
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 카탈로그를 보거나
|
|
148
|
+
* 아예 안 생길 수 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
|
|
149
|
+
*/
|
|
150
|
+
export function warnUnresolvedI18n(analysis, write) {
|
|
151
|
+
if (analysis.unresolved.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
write(` ! gaon.config.ts 의 i18n ${analysis.unresolved.join('·')} 을(를) 정적으로 읽지 못했습니다 — ` +
|
|
154
|
+
`타입 축(.gaon/messages.d.ts)과 doctor 는 기본값(dir='locales' · 기준=정렬 첫 로케일)을 씁니다.\n` +
|
|
155
|
+
` → 문자열 리터럴로 직접 쓰면 정확히 반영됩니다: i18n: { dir: 'locales', fallbackLng: 'ko' }\n`);
|
|
156
|
+
}
|
|
82
157
|
function isDefineConfig(e) {
|
|
83
158
|
if (ts.isIdentifier(e) && e.text === 'defineConfig')
|
|
84
159
|
return true;
|