@gaonjs/cli 0.2.0 → 0.4.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/__fixtures__/db-minimal/domain/schema/widgets.d.ts +12 -0
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +7 -0
- package/dist/__fixtures__/db-minimal/gaon.config.d.ts +2 -0
- package/dist/__fixtures__/db-minimal/gaon.config.js +11 -0
- package/dist/commands/db.d.ts +20 -0
- package/dist/commands/db.js +74 -0
- package/dist/commands/dev.d.ts +68 -0
- package/dist/commands/dev.js +287 -0
- package/dist/commands/g.d.ts +26 -0
- package/dist/commands/g.js +124 -0
- package/dist/db/diff.d.ts +17 -0
- package/dist/db/diff.js +57 -0
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.js +8 -0
- package/dist/db/migrate.d.ts +16 -0
- package/dist/db/migrate.js +173 -0
- package/dist/db/reset.d.ts +18 -0
- package/dist/db/reset.js +150 -0
- package/dist/db/resolve.d.ts +32 -0
- package/dist/db/resolve.js +130 -0
- package/dist/dev/console.d.ts +39 -0
- package/dist/dev/console.js +100 -0
- package/dist/dev/docker.d.ts +52 -0
- package/dist/dev/docker.js +163 -0
- package/dist/dev/index.d.ts +14 -0
- package/dist/dev/index.js +10 -0
- package/dist/dev/tsc.d.ts +41 -0
- package/dist/dev/tsc.js +127 -0
- package/dist/dev/watcher.d.ts +50 -0
- package/dist/dev/watcher.js +95 -0
- package/dist/dev.d.ts +1 -16
- package/dist/dev.js +10 -66
- package/dist/doctor/connections.d.ts +14 -0
- package/dist/doctor/connections.js +168 -0
- package/dist/doctor/dependency-direction.d.ts +11 -0
- package/dist/doctor/dependency-direction.js +186 -0
- package/dist/doctor/migration-diff.d.ts +11 -0
- package/dist/doctor/migration-diff.js +109 -0
- package/dist/doctor/n-plus-one.d.ts +5 -0
- package/dist/doctor/n-plus-one.js +242 -0
- package/dist/doctor/reporter.d.ts +5 -0
- package/dist/doctor/reporter.js +41 -0
- package/dist/doctor/response-mixing.d.ts +12 -0
- package/dist/doctor/response-mixing.js +158 -0
- package/dist/doctor/setup.d.ts +26 -0
- package/dist/doctor/setup.js +52 -0
- package/dist/doctor/types.d.ts +37 -0
- package/dist/doctor/types.js +34 -0
- package/dist/doctor.d.ts +40 -23
- package/dist/doctor.js +131 -202
- package/dist/index.d.ts +14 -2
- package/dist/index.js +171 -28
- package/dist/scaffold/controller.d.ts +12 -0
- package/dist/scaffold/controller.js +50 -0
- package/dist/scaffold/index.d.ts +20 -0
- package/dist/scaffold/index.js +41 -0
- package/dist/scaffold/inflect.d.ts +19 -0
- package/dist/scaffold/inflect.js +50 -0
- package/dist/scaffold/job.d.ts +3 -0
- package/dist/scaffold/job.js +46 -0
- package/dist/scaffold/model.d.ts +8 -0
- package/dist/scaffold/model.js +66 -0
- package/dist/scaffold/page.d.ts +7 -0
- package/dist/scaffold/page.js +46 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +79 -0
- package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
- package/dist/templates/auth/session.controller.ts.tpl +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · N+1 감지 (M9-E · errata E-4 (e))
|
|
2
|
+
//
|
|
3
|
+
// 컨트롤러 액션에서 belongsTo 관계 접근이 loop 안에 있고, 같은 액션에서
|
|
4
|
+
// `.include(<relation>)` 로 eager 로딩을 걸어 두지 않았으면 N+1 이다.
|
|
5
|
+
// 예: posts.map(p => p.author()) — 각 반복마다 DB 왕복 = N+1 쿼리.
|
|
6
|
+
//
|
|
7
|
+
// 정확도(spec): false positive 최소 · 애매하면 information warning.
|
|
8
|
+
// - AST 기반 · 스키마 로드 없이 순수 소스 분석
|
|
9
|
+
// - 위 패턴에 정확히 맞을 때만 warning(에러 아님)
|
|
10
|
+
// - 액션 내 어디서든 `.include('<name>')` 가 있으면 통과 (개발자 인지)
|
|
11
|
+
//
|
|
12
|
+
// 감지 대상 loop:
|
|
13
|
+
// - for (const p of items)
|
|
14
|
+
// - items.forEach(p => ...) · items.map(async p => ...) · .filter · .reduce
|
|
15
|
+
// loop 안 감지 대상 호출:
|
|
16
|
+
// - await p.<name>() (property access call · 인자 없음 · await 있음)
|
|
17
|
+
// - p.<name>() (문장 노드로 존재 · await 없어도 catch 가능)
|
|
18
|
+
//
|
|
19
|
+
// 감지 대상 아님(false positive 방지):
|
|
20
|
+
// - 문자열 리터럴만 있는 호출 · 정적 프로퍼티 접근 · 인자 있는 호출
|
|
21
|
+
// - 액션이 이미 `.include(<name>)` 를 사용한 경우
|
|
22
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
23
|
+
import { join, relative } from 'node:path';
|
|
24
|
+
import ts from 'typescript';
|
|
25
|
+
/** 소스 하나를 검사해 N+1 위반 목록을 낸다(단위 테스트 진입점). */
|
|
26
|
+
export function inspectControllerForNPlusOne(file, source, cwd) {
|
|
27
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
28
|
+
const actions = [];
|
|
29
|
+
// 컨트롤러 액션 수집
|
|
30
|
+
const visit = (node) => {
|
|
31
|
+
if (ts.isCallExpression(node) && isControllerCall(node)) {
|
|
32
|
+
const arg = node.arguments[0];
|
|
33
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
34
|
+
for (const prop of arg.properties) {
|
|
35
|
+
const nm = actionName(prop);
|
|
36
|
+
if (!nm)
|
|
37
|
+
continue;
|
|
38
|
+
const body = actionBody(prop);
|
|
39
|
+
if (!body || !ts.isBlock(body))
|
|
40
|
+
continue;
|
|
41
|
+
const { line } = sf.getLineAndCharacterOfPosition(prop.getStart(sf));
|
|
42
|
+
actions.push({ name: nm, line: line + 1, body });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
ts.forEachChild(node, visit);
|
|
47
|
+
};
|
|
48
|
+
visit(sf);
|
|
49
|
+
const issues = [];
|
|
50
|
+
const rel = relative(cwd, file);
|
|
51
|
+
for (const a of actions) {
|
|
52
|
+
const includedRelations = collectIncludes(a.body);
|
|
53
|
+
const violations = findNPlusOneInBlock(a.body, includedRelations, sf);
|
|
54
|
+
for (const v of violations) {
|
|
55
|
+
issues.push({
|
|
56
|
+
rule: 'n-plus-one',
|
|
57
|
+
level: 'warning',
|
|
58
|
+
file: rel,
|
|
59
|
+
line: v.line,
|
|
60
|
+
message: `N+1 감지: ${rel}:${v.line} · 액션 '${a.name}' 의 loop 안에서 ` +
|
|
61
|
+
`'${v.iter}.${v.rel}()' 를 호출합니다. 각 반복마다 DB 왕복이 발생합니다.\n` +
|
|
62
|
+
`→ loop 이전 쿼리에 '.include(\\'${v.rel}\\')' 를 붙여 eager 로딩하세요 (errata E-4 (e)).\n` +
|
|
63
|
+
`→ 예: const posts = await Post.include('${v.rel}').all()`,
|
|
64
|
+
detail: { action: a.name, relation: v.rel, iter: v.iter },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return issues;
|
|
69
|
+
}
|
|
70
|
+
/** 액션 본문 안의 모든 `.include('<name>')` 인자 이름을 수집한다. */
|
|
71
|
+
function collectIncludes(root) {
|
|
72
|
+
const names = new Set();
|
|
73
|
+
const visit = (node) => {
|
|
74
|
+
if (ts.isCallExpression(node) &&
|
|
75
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
76
|
+
node.expression.name.text === 'include') {
|
|
77
|
+
for (const arg of node.arguments) {
|
|
78
|
+
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))
|
|
79
|
+
names.add(arg.text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
ts.forEachChild(node, visit);
|
|
83
|
+
};
|
|
84
|
+
visit(root);
|
|
85
|
+
return names;
|
|
86
|
+
}
|
|
87
|
+
/** 블록 안의 loop 를 순회해 loop 반복 변수의 belongsTo 호출을 감지한다. */
|
|
88
|
+
function findNPlusOneInBlock(block, included, sf) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const scan = (node) => {
|
|
91
|
+
// for (const p of items)
|
|
92
|
+
if (ts.isForOfStatement(node)) {
|
|
93
|
+
const iter = forOfIterName(node);
|
|
94
|
+
if (iter)
|
|
95
|
+
scanLoopBody(node.statement, iter, included, out, sf);
|
|
96
|
+
}
|
|
97
|
+
// items.forEach / map / filter / reduce 등 · 콜백 첫 인자 = iter
|
|
98
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
99
|
+
const m = node.expression.name.text;
|
|
100
|
+
if (m === 'forEach' || m === 'map' || m === 'filter' || m === 'reduce') {
|
|
101
|
+
const cb = node.arguments[0];
|
|
102
|
+
if (cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb))) {
|
|
103
|
+
const p0 = cb.parameters[0];
|
|
104
|
+
if (p0 && ts.isIdentifier(p0.name)) {
|
|
105
|
+
const iter = p0.name.text;
|
|
106
|
+
const body = cb.body;
|
|
107
|
+
if (ts.isBlock(body))
|
|
108
|
+
scanLoopBody(body, iter, included, out, sf);
|
|
109
|
+
else
|
|
110
|
+
scanExpr(body, iter, included, out, sf);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
ts.forEachChild(node, scan);
|
|
116
|
+
};
|
|
117
|
+
scan(block);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
function forOfIterName(node) {
|
|
121
|
+
const init = node.initializer;
|
|
122
|
+
if (ts.isVariableDeclarationList(init) && init.declarations.length === 1) {
|
|
123
|
+
const d = init.declarations[0];
|
|
124
|
+
if (ts.isIdentifier(d.name))
|
|
125
|
+
return d.name.text;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
function scanLoopBody(body, iter, included, out, sf) {
|
|
130
|
+
const visit = (node) => {
|
|
131
|
+
// 중첩 함수는 스코프 다름 — 파고들지 않음(false positive 방지).
|
|
132
|
+
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))
|
|
133
|
+
return;
|
|
134
|
+
const matched = scanExpr(node, iter, included, out, sf);
|
|
135
|
+
// await x.y() 를 한 번 매치했으면 안쪽 CallExpression 을 다시 매치해
|
|
136
|
+
// 이중 리포트 하지 않도록 여기서 멈춘다.
|
|
137
|
+
if (matched)
|
|
138
|
+
return;
|
|
139
|
+
ts.forEachChild(node, visit);
|
|
140
|
+
};
|
|
141
|
+
visit(body);
|
|
142
|
+
}
|
|
143
|
+
function scanExpr(node, iter, included, out, sf) {
|
|
144
|
+
// await x.rel() 또는 x.rel() 두 형태 모두 catch.
|
|
145
|
+
// 인자가 없는 호출만 대상으로 삼는다(true belongsTo 시그니처).
|
|
146
|
+
const call = ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)
|
|
147
|
+
? node.expression
|
|
148
|
+
: ts.isCallExpression(node)
|
|
149
|
+
? node
|
|
150
|
+
: undefined;
|
|
151
|
+
if (!call)
|
|
152
|
+
return false;
|
|
153
|
+
if (call.arguments.length !== 0)
|
|
154
|
+
return false;
|
|
155
|
+
const e = call.expression;
|
|
156
|
+
if (!ts.isPropertyAccessExpression(e))
|
|
157
|
+
return false;
|
|
158
|
+
if (!ts.isIdentifier(e.expression))
|
|
159
|
+
return false;
|
|
160
|
+
if (e.expression.text !== iter)
|
|
161
|
+
return false;
|
|
162
|
+
const relName = e.name.text;
|
|
163
|
+
// include 로 미리 로드했으면 통과.
|
|
164
|
+
if (included.has(relName))
|
|
165
|
+
return true;
|
|
166
|
+
// 알려진 배열/이터레이션 원시 메서드는 제외(map/filter 등이 iter 위에 다시 걸리는 케이스).
|
|
167
|
+
if (BUILTIN_ITER_METHODS.has(relName))
|
|
168
|
+
return true;
|
|
169
|
+
const { line } = sf.getLineAndCharacterOfPosition(call.getStart(sf));
|
|
170
|
+
out.push({ iter, rel: relName, line: line + 1 });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
const BUILTIN_ITER_METHODS = new Set([
|
|
174
|
+
'toString',
|
|
175
|
+
'valueOf',
|
|
176
|
+
'toJSON',
|
|
177
|
+
'toLocaleString',
|
|
178
|
+
'hasOwnProperty',
|
|
179
|
+
]);
|
|
180
|
+
function isControllerCall(node) {
|
|
181
|
+
const e = node.expression;
|
|
182
|
+
if (ts.isIdentifier(e) && e.text === 'controller')
|
|
183
|
+
return true;
|
|
184
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
|
|
185
|
+
return true;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
function actionName(prop) {
|
|
189
|
+
if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
|
|
190
|
+
return prop.name.text;
|
|
191
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
192
|
+
ts.isIdentifier(prop.name) &&
|
|
193
|
+
(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
|
|
194
|
+
return prop.name.text;
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
function actionBody(prop) {
|
|
199
|
+
if (ts.isMethodDeclaration(prop))
|
|
200
|
+
return prop.body;
|
|
201
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
202
|
+
if (ts.isArrowFunction(prop.initializer))
|
|
203
|
+
return prop.initializer.body;
|
|
204
|
+
if (ts.isFunctionExpression(prop.initializer))
|
|
205
|
+
return prop.initializer.body;
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
/** apps/ 를 스캔해 N+1 검사를 돌린다. */
|
|
210
|
+
export async function checkNPlusOne(cwd) {
|
|
211
|
+
const appsDir = join(cwd, 'apps');
|
|
212
|
+
const issues = [];
|
|
213
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
214
|
+
const ctrlDir = join(appsDir, app, 'controllers');
|
|
215
|
+
for (const file of await safeListFiles(ctrlDir)) {
|
|
216
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
|
|
217
|
+
continue;
|
|
218
|
+
const full = join(ctrlDir, file);
|
|
219
|
+
const source = await readFile(full, 'utf8');
|
|
220
|
+
issues.push(...inspectControllerForNPlusOne(full, source, cwd));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { rule: 'n-plus-one', issues };
|
|
224
|
+
}
|
|
225
|
+
async function safeListDirs(dir) {
|
|
226
|
+
try {
|
|
227
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
228
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function safeListFiles(dir) {
|
|
235
|
+
try {
|
|
236
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
237
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 출력 포맷 (human / JSON) — M9-E · M9-E-Fix
|
|
2
|
+
//
|
|
3
|
+
// human: 사람이 읽는 요약 + 규칙별 이슈 목록.
|
|
4
|
+
// JSON: 자동화(CI)용 · {passed, warnings, errors, fatal?} 그대로 직렬화
|
|
5
|
+
// · 파싱 안정.
|
|
6
|
+
//
|
|
7
|
+
// fatal 이 있으면 규칙 실행 자체가 불가능한 상황 — 크래시 대신 우아한
|
|
8
|
+
// 안내(§7.5.3 · 에러 = 수리 안내서)를 출력한다.
|
|
9
|
+
/** JSON 출력 문자열(끝 개행 없음). fatal 이 있으면 그대로 포함된다. */
|
|
10
|
+
export function renderJson(result) {
|
|
11
|
+
return JSON.stringify(result);
|
|
12
|
+
}
|
|
13
|
+
/** 사람이 읽는 요약(끝 개행 없음). */
|
|
14
|
+
export function renderHuman(result) {
|
|
15
|
+
if (result.fatal) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
lines.push(` ✗ gaon doctor · 시작 불가 (${result.fatal.code})`);
|
|
18
|
+
for (const ln of result.fatal.message.split('\n')) {
|
|
19
|
+
lines.push(` ${ln}`);
|
|
20
|
+
}
|
|
21
|
+
lines.push(` ${result.fatal.hint}`);
|
|
22
|
+
return lines.join('\n');
|
|
23
|
+
}
|
|
24
|
+
const lines = [];
|
|
25
|
+
const passN = result.passed.length;
|
|
26
|
+
const warnN = result.warnings.length;
|
|
27
|
+
const errN = result.errors.length;
|
|
28
|
+
lines.push(` gaon doctor · 통과 ${passN} · 경고 ${warnN} · 오류 ${errN}`);
|
|
29
|
+
for (const p of result.passed) {
|
|
30
|
+
lines.push(` ✓ ${p.rule} — ${p.message}`);
|
|
31
|
+
}
|
|
32
|
+
for (const w of result.warnings) {
|
|
33
|
+
lines.push(``);
|
|
34
|
+
lines.push(` ! [${w.rule}] ${w.message}`);
|
|
35
|
+
}
|
|
36
|
+
for (const e of result.errors) {
|
|
37
|
+
lines.push(``);
|
|
38
|
+
lines.push(` ✗ [${e.rule}] ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
export type ResponseKind = 'render' | 'redirect' | 'json' | 'plain';
|
|
3
|
+
export interface ActionUsage {
|
|
4
|
+
readonly file: string;
|
|
5
|
+
readonly action: string;
|
|
6
|
+
readonly line: number;
|
|
7
|
+
readonly kinds: readonly ResponseKind[];
|
|
8
|
+
}
|
|
9
|
+
/** 소스 문자열 하나에서 액션별 응답 종류를 수집한다(단위 테스트 진입점). */
|
|
10
|
+
export declare function inspectControllerSource(file: string, source: string): ActionUsage[];
|
|
11
|
+
/** apps/ 를 훑어 응답 혼용 위반을 모두 낸다. */
|
|
12
|
+
export declare function checkResponseMixing(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 응답 혼용 검사 (M3 · errata E-3 §C · M9-E 로 이관)
|
|
2
|
+
//
|
|
3
|
+
// 한 컨트롤러 액션은 render/JSON/redirect 중 한 가지 응답 형태만 낸다.
|
|
4
|
+
// 조건 분기로 섞이면 "이 라우트가 무엇을 돌려주는지" 를 코드를 읽어야
|
|
5
|
+
// 알게 돼 AI 첫 시도 성공률이 떨어진다.
|
|
6
|
+
//
|
|
7
|
+
// 정적 분석: apps/<app>/controllers/*.ts 를 TS AST 로 파싱, 액션마다
|
|
8
|
+
// return 노드에서 최종 응답 형태를 분류(render | redirect | json | plain).
|
|
9
|
+
// 서로 다른 형태가 두 종류 이상이면 위반(error 등급).
|
|
10
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
11
|
+
import { join, relative } from 'node:path';
|
|
12
|
+
import ts from 'typescript';
|
|
13
|
+
/** 소스 문자열 하나에서 액션별 응답 종류를 수집한다(단위 테스트 진입점). */
|
|
14
|
+
export function inspectControllerSource(file, source) {
|
|
15
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
16
|
+
const usages = [];
|
|
17
|
+
const visit = (node) => {
|
|
18
|
+
if (ts.isCallExpression(node) && isControllerCall(node)) {
|
|
19
|
+
const arg = node.arguments[0];
|
|
20
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
21
|
+
for (const prop of arg.properties) {
|
|
22
|
+
const action = actionName(prop);
|
|
23
|
+
if (!action)
|
|
24
|
+
continue;
|
|
25
|
+
const body = actionBody(prop);
|
|
26
|
+
if (!body)
|
|
27
|
+
continue;
|
|
28
|
+
const kinds = classifyReturns(body);
|
|
29
|
+
const { line } = sf.getLineAndCharacterOfPosition(prop.getStart(sf));
|
|
30
|
+
usages.push({ file, action, line: line + 1, kinds });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
ts.forEachChild(node, visit);
|
|
35
|
+
};
|
|
36
|
+
visit(sf);
|
|
37
|
+
return usages;
|
|
38
|
+
}
|
|
39
|
+
function isControllerCall(node) {
|
|
40
|
+
const e = node.expression;
|
|
41
|
+
if (ts.isIdentifier(e) && e.text === 'controller')
|
|
42
|
+
return true;
|
|
43
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
|
|
44
|
+
return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function actionName(prop) {
|
|
48
|
+
if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
|
|
49
|
+
return prop.name.text;
|
|
50
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
51
|
+
ts.isIdentifier(prop.name) &&
|
|
52
|
+
(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
|
|
53
|
+
return prop.name.text;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
function actionBody(prop) {
|
|
58
|
+
if (ts.isMethodDeclaration(prop))
|
|
59
|
+
return prop.body;
|
|
60
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
61
|
+
if (ts.isArrowFunction(prop.initializer))
|
|
62
|
+
return prop.initializer.body;
|
|
63
|
+
if (ts.isFunctionExpression(prop.initializer))
|
|
64
|
+
return prop.initializer.body;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function classifyReturns(body) {
|
|
69
|
+
const kinds = new Set();
|
|
70
|
+
const classify = (expr) => {
|
|
71
|
+
if (ts.isAwaitExpression(expr))
|
|
72
|
+
return classify(expr.expression);
|
|
73
|
+
if (ts.isCallExpression(expr)) {
|
|
74
|
+
const c = expr.expression;
|
|
75
|
+
if (ts.isPropertyAccessExpression(c) && c.expression.kind === ts.SyntaxKind.ThisKeyword) {
|
|
76
|
+
if (c.name.text === 'render')
|
|
77
|
+
return 'render';
|
|
78
|
+
if (c.name.text === 'redirect')
|
|
79
|
+
return 'redirect';
|
|
80
|
+
if (c.name.text === 'json')
|
|
81
|
+
return 'json';
|
|
82
|
+
}
|
|
83
|
+
return 'plain';
|
|
84
|
+
}
|
|
85
|
+
if (ts.isConditionalExpression(expr)) {
|
|
86
|
+
kinds.add(classify(expr.whenTrue));
|
|
87
|
+
kinds.add(classify(expr.whenFalse));
|
|
88
|
+
return classify(expr.whenTrue);
|
|
89
|
+
}
|
|
90
|
+
return 'plain';
|
|
91
|
+
};
|
|
92
|
+
const visit = (node) => {
|
|
93
|
+
if (ts.isReturnStatement(node)) {
|
|
94
|
+
if (node.expression)
|
|
95
|
+
kinds.add(classify(node.expression));
|
|
96
|
+
else
|
|
97
|
+
kinds.add('plain');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))
|
|
101
|
+
return;
|
|
102
|
+
ts.forEachChild(node, visit);
|
|
103
|
+
};
|
|
104
|
+
if (ts.isBlock(body))
|
|
105
|
+
visit(body);
|
|
106
|
+
else
|
|
107
|
+
kinds.add(classify(body));
|
|
108
|
+
return [...kinds];
|
|
109
|
+
}
|
|
110
|
+
/** apps/ 를 훑어 응답 혼용 위반을 모두 낸다. */
|
|
111
|
+
export async function checkResponseMixing(cwd) {
|
|
112
|
+
const appsDir = join(cwd, 'apps');
|
|
113
|
+
const issues = [];
|
|
114
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
115
|
+
const ctrlDir = join(appsDir, app, 'controllers');
|
|
116
|
+
for (const file of await safeListFiles(ctrlDir)) {
|
|
117
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
|
|
118
|
+
continue;
|
|
119
|
+
const full = join(ctrlDir, file);
|
|
120
|
+
const source = await readFile(full, 'utf8');
|
|
121
|
+
const usages = inspectControllerSource(full, source);
|
|
122
|
+
for (const u of usages) {
|
|
123
|
+
if (u.kinds.length < 2)
|
|
124
|
+
continue;
|
|
125
|
+
const rel = relative(cwd, full);
|
|
126
|
+
issues.push({
|
|
127
|
+
rule: 'response-mixing',
|
|
128
|
+
level: 'error',
|
|
129
|
+
file: rel,
|
|
130
|
+
line: u.line,
|
|
131
|
+
message: `응답 혼용 감지: ${rel}:${u.line} · 액션 '${u.action}' 이 서로 다른 응답 형태를 섞습니다 (${u.kinds.join(', ')}).\n` +
|
|
132
|
+
`→ 한 액션은 한 응답 형태만 유지하세요 (render | JSON | redirect · errata E-3 §C).\n` +
|
|
133
|
+
`→ 조건 분기가 필요하면 액션을 둘로 나누거나, JSON 반환에는 예외를 던져 상태 코드를 표현하세요.`,
|
|
134
|
+
detail: { action: u.action, kinds: [...u.kinds] },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { rule: 'response-mixing', issues };
|
|
140
|
+
}
|
|
141
|
+
async function safeListDirs(dir) {
|
|
142
|
+
try {
|
|
143
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
144
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function safeListFiles(dir) {
|
|
151
|
+
try {
|
|
152
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
153
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DoctorFatal } from './types.js';
|
|
2
|
+
export type { DoctorFatal, DoctorFatalCode } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* 프로젝트 마커 4종: gaon.config.ts · domain/ · apps/ · shared/.
|
|
5
|
+
* 하나라도 있으면 gaon 프로젝트로 인정한다. 모두 없으면 검사할 대상이
|
|
6
|
+
* 없으므로 우아한 안내 후 종료한다.
|
|
7
|
+
*/
|
|
8
|
+
export declare function detectProject(cwd: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* typescript 모듈이 doctor 가 필요로 하는 compiler API 를 노출하는지
|
|
11
|
+
* 검사한다. 예: 사용자 프로젝트가 typescript@7 을 받아 오면 default
|
|
12
|
+
* export 가 `{ version, versionMajorMinor }` 스텁만 담아
|
|
13
|
+
* `ts.ScriptTarget` 이 undefined → `ts.createSourceFile` 호출 시 크래시.
|
|
14
|
+
*
|
|
15
|
+
* 인자 tsMod 는 테스트에서 스텁을 주입하기 위해 기본값을 실 ts 로 둔다.
|
|
16
|
+
*/
|
|
17
|
+
export declare function checkTypeScriptApi(tsMod?: {
|
|
18
|
+
readonly ScriptTarget?: {
|
|
19
|
+
readonly ES2022?: number;
|
|
20
|
+
};
|
|
21
|
+
readonly createSourceFile?: unknown;
|
|
22
|
+
}): boolean;
|
|
23
|
+
/** 프로젝트 마커 없음 · 우아한 안내. */
|
|
24
|
+
export declare function fatalNoProject(cwd: string): DoctorFatal;
|
|
25
|
+
/** typescript compiler API 미노출 · 우아한 안내. */
|
|
26
|
+
export declare function fatalTsApiMissing(installedVersion: string | undefined): DoctorFatal;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 사전 검사 (M9-E-Fix · 크래시 하드닝)
|
|
2
|
+
//
|
|
3
|
+
// doctor 는 사용자 프로젝트의 TS 소스를 AST 로 파싱한다. 두 가지 전제:
|
|
4
|
+
// 1) 실제로 gaon 프로젝트여야 한다 (검사 대상이 존재)
|
|
5
|
+
// 2) typescript 컴파일러 API 를 불러올 수 있어야 한다
|
|
6
|
+
// 전제가 깨지면 규칙 실행 중 크래시("Cannot read properties of undefined
|
|
7
|
+
// (reading 'ES2022')" 등) 대신 우아한 안내 메시지 + exit 2 로 대체한다
|
|
8
|
+
// (CLAUDE.md §7.5.3 · 에러 = 수리 안내서).
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import ts from 'typescript';
|
|
12
|
+
/**
|
|
13
|
+
* 프로젝트 마커 4종: gaon.config.ts · domain/ · apps/ · shared/.
|
|
14
|
+
* 하나라도 있으면 gaon 프로젝트로 인정한다. 모두 없으면 검사할 대상이
|
|
15
|
+
* 없으므로 우아한 안내 후 종료한다.
|
|
16
|
+
*/
|
|
17
|
+
export function detectProject(cwd) {
|
|
18
|
+
const markers = ['gaon.config.ts', 'domain', 'apps', 'shared'];
|
|
19
|
+
return markers.some((m) => existsSync(join(cwd, m)));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* typescript 모듈이 doctor 가 필요로 하는 compiler API 를 노출하는지
|
|
23
|
+
* 검사한다. 예: 사용자 프로젝트가 typescript@7 을 받아 오면 default
|
|
24
|
+
* export 가 `{ version, versionMajorMinor }` 스텁만 담아
|
|
25
|
+
* `ts.ScriptTarget` 이 undefined → `ts.createSourceFile` 호출 시 크래시.
|
|
26
|
+
*
|
|
27
|
+
* 인자 tsMod 는 테스트에서 스텁을 주입하기 위해 기본값을 실 ts 로 둔다.
|
|
28
|
+
*/
|
|
29
|
+
export function checkTypeScriptApi(tsMod = ts) {
|
|
30
|
+
return (typeof tsMod.createSourceFile === 'function' &&
|
|
31
|
+
tsMod.ScriptTarget !== undefined &&
|
|
32
|
+
tsMod.ScriptTarget.ES2022 !== undefined);
|
|
33
|
+
}
|
|
34
|
+
/** 프로젝트 마커 없음 · 우아한 안내. */
|
|
35
|
+
export function fatalNoProject(cwd) {
|
|
36
|
+
return {
|
|
37
|
+
code: 'no-project',
|
|
38
|
+
message: `gaon 프로젝트를 찾지 못했습니다 (cwd: ${cwd}).\n` +
|
|
39
|
+
`마커(gaon.config.ts · domain/ · apps/ · shared/) 중 하나도 없습니다.`,
|
|
40
|
+
hint: `→ 'gaon new <name>' 으로 새 프로젝트를 만들거나 실 프로젝트 경로로 이동한 뒤 다시 실행하세요.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** typescript compiler API 미노출 · 우아한 안내. */
|
|
44
|
+
export function fatalTsApiMissing(installedVersion) {
|
|
45
|
+
const v = installedVersion ?? '(알 수 없음)';
|
|
46
|
+
return {
|
|
47
|
+
code: 'ts-api-missing',
|
|
48
|
+
message: `typescript 컴파일러 API 를 불러올 수 없습니다 (설치된 typescript: ${v}).\n` +
|
|
49
|
+
`doctor 는 typescript 5.x 의 AST 파서(createSourceFile · ScriptTarget)를 사용합니다.`,
|
|
50
|
+
hint: `→ 프로젝트에 typescript 5.x 를 설치하세요: 'npm i -D typescript@^5.9'.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff';
|
|
2
|
+
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
|
+
export interface DoctorCheck {
|
|
4
|
+
readonly rule: DoctorRule;
|
|
5
|
+
readonly level: DoctorLevel;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
/** 위반이 발생한 소스 파일(cwd 기준 상대). passed 는 undefined. */
|
|
8
|
+
readonly file?: string;
|
|
9
|
+
/** 1-기반 라인 번호. */
|
|
10
|
+
readonly line?: number;
|
|
11
|
+
/** 규칙별 부가 정보(응답 종류 목록 · 관계 이름 · 위반 방향 등). */
|
|
12
|
+
readonly detail?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface DoctorResult {
|
|
15
|
+
readonly passed: readonly DoctorCheck[];
|
|
16
|
+
readonly warnings: readonly DoctorCheck[];
|
|
17
|
+
readonly errors: readonly DoctorCheck[];
|
|
18
|
+
/**
|
|
19
|
+
* 사전 검사 실패(프로젝트 없음 · TS API 미노출) 시에만 채워진다.
|
|
20
|
+
* 존재하면 runCli 는 exit 2 로 종료한다(사용자 오류 · 크래시 아님).
|
|
21
|
+
*/
|
|
22
|
+
readonly fatal?: DoctorFatal;
|
|
23
|
+
}
|
|
24
|
+
/** 사전 검사 실패 원인 코드. */
|
|
25
|
+
export type DoctorFatalCode = 'no-project' | 'ts-api-missing';
|
|
26
|
+
/** 규칙 실행이 시작될 수 없는 상황. runCli 는 exit 2 로 매핑. */
|
|
27
|
+
export interface DoctorFatal {
|
|
28
|
+
readonly code: DoctorFatalCode;
|
|
29
|
+
readonly message: string;
|
|
30
|
+
readonly hint: string;
|
|
31
|
+
}
|
|
32
|
+
/** 하위 검사 모듈이 상위에서 조립되도록 반환하는 결과 묶음. */
|
|
33
|
+
export interface RuleReport {
|
|
34
|
+
readonly rule: DoctorRule;
|
|
35
|
+
readonly issues: readonly DoctorCheck[];
|
|
36
|
+
}
|
|
37
|
+
export declare function makeResult(reports: readonly RuleReport[]): DoctorResult;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix)
|
|
2
|
+
//
|
|
3
|
+
// 5 검사(response-mixing · n-plus-one · dependency-direction · connections
|
|
4
|
+
// · migration-diff)가 모두 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는
|
|
5
|
+
// level 로 passed/warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을
|
|
6
|
+
// 파싱해 errors.length > 0 이면 fail 로 판단한다.
|
|
7
|
+
//
|
|
8
|
+
// 사전 검사 실패(프로젝트 마커 없음 · TS API 미노출)는 규칙 실행 자체가
|
|
9
|
+
// 불가능한 상황이므로 별도 `fatal` 필드로 표현한다 — runCli 는 이 경우
|
|
10
|
+
// exit 2(사용자 오류)로 매핑한다(M9-E-Fix · CLAUDE.md §7.5.3).
|
|
11
|
+
export function makeResult(reports) {
|
|
12
|
+
const passed = [];
|
|
13
|
+
const warnings = [];
|
|
14
|
+
const errors = [];
|
|
15
|
+
for (const r of reports) {
|
|
16
|
+
if (r.issues.length === 0) {
|
|
17
|
+
passed.push({
|
|
18
|
+
rule: r.rule,
|
|
19
|
+
level: 'passed',
|
|
20
|
+
message: `${r.rule} · 통과`,
|
|
21
|
+
});
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
for (const i of r.issues) {
|
|
25
|
+
if (i.level === 'error')
|
|
26
|
+
errors.push(i);
|
|
27
|
+
else if (i.level === 'warning')
|
|
28
|
+
warnings.push(i);
|
|
29
|
+
else
|
|
30
|
+
passed.push(i);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { passed, warnings, errors };
|
|
34
|
+
}
|