@gaonjs/cli 0.2.0 → 0.3.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 (37) hide show
  1. package/dist/commands/g.d.ts +26 -0
  2. package/dist/commands/g.js +124 -0
  3. package/dist/doctor/connections.d.ts +14 -0
  4. package/dist/doctor/connections.js +168 -0
  5. package/dist/doctor/dependency-direction.d.ts +11 -0
  6. package/dist/doctor/dependency-direction.js +186 -0
  7. package/dist/doctor/migration-diff.d.ts +11 -0
  8. package/dist/doctor/migration-diff.js +109 -0
  9. package/dist/doctor/n-plus-one.d.ts +5 -0
  10. package/dist/doctor/n-plus-one.js +242 -0
  11. package/dist/doctor/reporter.d.ts +5 -0
  12. package/dist/doctor/reporter.js +28 -0
  13. package/dist/doctor/response-mixing.d.ts +12 -0
  14. package/dist/doctor/response-mixing.js +158 -0
  15. package/dist/doctor/types.d.ts +24 -0
  16. package/dist/doctor/types.js +30 -0
  17. package/dist/doctor.d.ts +26 -23
  18. package/dist/doctor.js +76 -206
  19. package/dist/index.d.ts +9 -1
  20. package/dist/index.js +101 -8
  21. package/dist/scaffold/controller.d.ts +12 -0
  22. package/dist/scaffold/controller.js +50 -0
  23. package/dist/scaffold/index.d.ts +20 -0
  24. package/dist/scaffold/index.js +41 -0
  25. package/dist/scaffold/inflect.d.ts +19 -0
  26. package/dist/scaffold/inflect.js +50 -0
  27. package/dist/scaffold/job.d.ts +3 -0
  28. package/dist/scaffold/job.js +46 -0
  29. package/dist/scaffold/model.d.ts +8 -0
  30. package/dist/scaffold/model.js +66 -0
  31. package/dist/scaffold/page.d.ts +7 -0
  32. package/dist/scaffold/page.js +46 -0
  33. package/dist/serve.d.ts +18 -0
  34. package/dist/serve.js +79 -0
  35. package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
  36. package/dist/templates/auth/session.controller.ts.tpl +1 -1
  37. package/package.json +5 -4
@@ -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,5 @@
1
+ import type { DoctorResult } from './types.js';
2
+ /** JSON 출력 문자열(끝 개행 없음). */
3
+ export declare function renderJson(result: DoctorResult): string;
4
+ /** 사람이 읽는 요약(끝 개행 없음). */
5
+ export declare function renderHuman(result: DoctorResult): string;
@@ -0,0 +1,28 @@
1
+ // @gaonjs/cli · doctor · 출력 포맷 (human / JSON) — M9-E
2
+ //
3
+ // human: 사람이 읽는 요약 + 규칙별 이슈 목록.
4
+ // JSON: 자동화(CI)용 · {passed, warnings, errors} 그대로 직렬화 · 파싱 안정.
5
+ /** JSON 출력 문자열(끝 개행 없음). */
6
+ export function renderJson(result) {
7
+ return JSON.stringify(result);
8
+ }
9
+ /** 사람이 읽는 요약(끝 개행 없음). */
10
+ export function renderHuman(result) {
11
+ const lines = [];
12
+ const passN = result.passed.length;
13
+ const warnN = result.warnings.length;
14
+ const errN = result.errors.length;
15
+ lines.push(` gaon doctor · 통과 ${passN} · 경고 ${warnN} · 오류 ${errN}`);
16
+ for (const p of result.passed) {
17
+ lines.push(` ✓ ${p.rule} — ${p.message}`);
18
+ }
19
+ for (const w of result.warnings) {
20
+ lines.push(``);
21
+ lines.push(` ! [${w.rule}] ${w.message}`);
22
+ }
23
+ for (const e of result.errors) {
24
+ lines.push(``);
25
+ lines.push(` ✗ [${e.rule}] ${e.message}`);
26
+ }
27
+ return lines.join('\n');
28
+ }
@@ -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,24 @@
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
+ /** 하위 검사 모듈이 상위에서 조립되도록 반환하는 결과 묶음. */
20
+ export interface RuleReport {
21
+ readonly rule: DoctorRule;
22
+ readonly issues: readonly DoctorCheck[];
23
+ }
24
+ export declare function makeResult(reports: readonly RuleReport[]): DoctorResult;
@@ -0,0 +1,30 @@
1
+ // @gaonjs/cli · doctor 공용 타입 (M9-E)
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
+ export function makeResult(reports) {
8
+ const passed = [];
9
+ const warnings = [];
10
+ const errors = [];
11
+ for (const r of reports) {
12
+ if (r.issues.length === 0) {
13
+ passed.push({
14
+ rule: r.rule,
15
+ level: 'passed',
16
+ message: `${r.rule} · 통과`,
17
+ });
18
+ continue;
19
+ }
20
+ for (const i of r.issues) {
21
+ if (i.level === 'error')
22
+ errors.push(i);
23
+ else if (i.level === 'warning')
24
+ warnings.push(i);
25
+ else
26
+ passed.push(i);
27
+ }
28
+ }
29
+ return { passed, warnings, errors };
30
+ }
package/dist/doctor.d.ts CHANGED
@@ -1,36 +1,39 @@
1
- export type ResponseKind = 'render' | 'redirect' | 'json' | 'plain';
2
- export interface ActionUsage {
3
- readonly file: string;
4
- readonly action: string;
5
- readonly line: number;
6
- readonly kinds: readonly ResponseKind[];
1
+ import { type DoctorCheck, type DoctorLevel, type DoctorResult, type DoctorRule, type RuleReport } from './doctor/types.js';
2
+ export type { DoctorCheck, DoctorLevel, DoctorResult, DoctorRule, RuleReport };
3
+ export type { ResponseKind, ActionUsage } from './doctor/response-mixing.js';
4
+ export { inspectControllerSource, checkResponseMixing } from './doctor/response-mixing.js';
5
+ export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one.js';
6
+ export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
7
+ export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
8
+ export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
9
+ export { renderHuman, renderJson } from './doctor/reporter.js';
10
+ export interface DoctorCommandOptions {
11
+ readonly cwd?: string;
12
+ readonly json?: boolean;
13
+ readonly checks?: readonly DoctorRule[];
7
14
  }
15
+ /**
16
+ * `gaon doctor` 진입점 (M9-E · 확장).
17
+ *
18
+ * 반환: DoctorResult({passed, warnings, errors}).
19
+ * exit code 는 진입점(runCli)이 errors.length 로 결정한다.
20
+ */
21
+ export declare function runDoctorCommand(opts?: DoctorCommandOptions): Promise<DoctorResult>;
8
22
  export interface DoctorIssue {
9
23
  readonly file: string;
10
24
  readonly line: number;
11
25
  readonly action: string;
12
- readonly kinds: readonly ResponseKind[];
26
+ readonly kinds: readonly string[];
13
27
  readonly message: string;
14
28
  }
15
- export interface DoctorResult {
29
+ export interface LegacyDoctorResult {
16
30
  readonly ok: boolean;
17
31
  readonly checked: number;
18
32
  readonly issues: readonly DoctorIssue[];
19
33
  }
20
- /** 컨트롤러 파일 하나를 검사한다. */
21
- export declare function inspectControllerSource(file: string, source: string): ActionUsage[];
22
34
  /**
23
- * apps/<app>/controllers/ 훑어 응답 혼용 액션을 찾는다. 액션 하나의 kinds
24
- * 배열이 2종 이상이면 위반(단, 'plain' 하나만 있는 것과 render 만 있는 것은 통과).
25
- *
26
- * 명시적 예외: 반환 없는 return(=undefined→204)만 있는 경우는 통과 — void 액션은
27
- * 흔히 혼용 없이 정상적으로 쓰인다.
35
+ * 하위 호환: 응답 혼용만 검사해 legacy shape 낸다.
36
+ * appsDir 프로젝트의 apps/ 절대 경로. 상위(진입점)는 runDoctorCommand
37
+ * 를 쓴다.
28
38
  */
29
- export declare function runDoctor(appsDir: string): Promise<DoctorResult>;
30
- export declare function fileExists(file: string): Promise<boolean>;
31
- export interface DoctorCommandOptions {
32
- readonly cwd?: string;
33
- readonly json?: boolean;
34
- }
35
- /** `gaon doctor` 진입점. */
36
- export declare function runDoctorCommand(opts?: DoctorCommandOptions): Promise<number>;
39
+ export declare function runDoctor(appsDir: string): Promise<LegacyDoctorResult>;