@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
package/dist/dev.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { WatchHandle, WatchOptions } from '@gaonjs/data';
|
|
2
2
|
export interface DevApp {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
readonly appDir: string;
|
|
@@ -42,18 +42,3 @@ export interface DevHandle {
|
|
|
42
42
|
export declare function startDev(deps: DevDeps): Promise<DevHandle>;
|
|
43
43
|
/** cwd 관례로 프로젝트 레이아웃을 해석한다(존재하는 것만 포함). */
|
|
44
44
|
export declare function resolveDevLayout(cwd: string): DevLayout;
|
|
45
|
-
export interface DevCommandOptions {
|
|
46
|
-
readonly cwd?: string;
|
|
47
|
-
readonly json?: boolean;
|
|
48
|
-
/** 프로세스 시그널 등록·해제(테스트에서 주입 가능). 기본 process. */
|
|
49
|
-
readonly signals?: {
|
|
50
|
-
on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
|
|
51
|
-
off(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* `gaon dev` 진입점. cwd 관례로 레이아웃을 엮고 워치 루프를 띄운 뒤
|
|
56
|
-
* SIGINT/SIGTERM 에 graceful stop. 프로세스는 워처가 살아 있는 동안
|
|
57
|
-
* 유지된다(활성 핸들). 반환 프라미스는 종료 시 resolve.
|
|
58
|
-
*/
|
|
59
|
-
export declare function runDevCommand(opts?: DevCommandOptions): Promise<void>;
|
package/dist/dev.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @gaonjs/cli ·
|
|
2
|
+
* @gaonjs/cli · dev — .gaon 갱신 파이프라인 (M3 · v0.15 §6.3)
|
|
3
3
|
*
|
|
4
4
|
* 파일 워처(@gaonjs/data watchDir) → 재생성(tables.d.ts·routes.d.ts) →
|
|
5
|
-
* 디스크 mtime 갱신 → 편집기 tsserver 자동
|
|
5
|
+
* 디스크 mtime 갱신 → 편집기 tsserver 자동 반영. 오케스트레이션은
|
|
6
|
+
* 주입(deps)으로 순수하게 유지하고(테스트 용이 · dbCli 패턴 동일),
|
|
7
|
+
* 상위(gaon dev 오케스트레이터 · commands/dev.ts)가 이 startDev 를 재사용한다.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* M9-C 이전에는 이 파일이 gaon dev 진입점(runDevCommand)도 겸했으나,
|
|
10
|
+
* M9-C 에서 인프라·서버·타입 검사기를 통합하는 새 진입점이 commands/dev.ts
|
|
11
|
+
* 로 이동했다. 이 파일은 .gaon 재생성 코어(startDev · resolveDevLayout) 만
|
|
12
|
+
* 유지한다.
|
|
11
13
|
*/
|
|
12
14
|
import { readdirSync, existsSync } from 'node:fs';
|
|
13
15
|
import { join, resolve } from 'node:path';
|
|
14
|
-
import { generateTablesDts, watchDir } from '@gaonjs/data';
|
|
15
|
-
import { generateRoutesDts } from '@gaonjs/web';
|
|
16
|
-
import { registerTsResolve } from './tsResolve.js';
|
|
17
16
|
const isSchemaChange = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && !f.includes('.gaon');
|
|
18
17
|
const isRoutesChange = (f) => !f.includes('.gaon') &&
|
|
19
18
|
f.endsWith('.ts') &&
|
|
@@ -94,60 +93,5 @@ export function resolveDevLayout(cwd) {
|
|
|
94
93
|
apps: apps.sort((a, b) => a.name.localeCompare(b.name)),
|
|
95
94
|
};
|
|
96
95
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
switch (e.kind) {
|
|
100
|
-
case 'ready': {
|
|
101
|
-
const parts = [];
|
|
102
|
-
if (e.schema)
|
|
103
|
-
parts.push('schema→tables.d.ts');
|
|
104
|
-
for (const a of e.apps)
|
|
105
|
-
parts.push(`${a}→routes.d.ts`);
|
|
106
|
-
const what = parts.length ? parts.join(', ') : '(감시 대상 없음 — domain/schema·apps/* 확인)';
|
|
107
|
-
return ` gaon dev · 워치 시작 — ${what}\n 파일을 저장하면 .gaon 이 재생성되고 편집기가 자동 반영합니다. (Ctrl+C 종료)`;
|
|
108
|
-
}
|
|
109
|
-
case 'regen':
|
|
110
|
-
return e.target === 'tables'
|
|
111
|
-
? ' ↻ tables.d.ts 재생성'
|
|
112
|
-
: ` ↻ ${e.app}/.gaon/routes.d.ts 재생성`;
|
|
113
|
-
case 'stopped':
|
|
114
|
-
return ' gaon dev · 종료';
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* `gaon dev` 진입점. cwd 관례로 레이아웃을 엮고 워치 루프를 띄운 뒤
|
|
119
|
-
* SIGINT/SIGTERM 에 graceful stop. 프로세스는 워처가 살아 있는 동안
|
|
120
|
-
* 유지된다(활성 핸들). 반환 프라미스는 종료 시 resolve.
|
|
121
|
-
*/
|
|
122
|
-
export async function runDevCommand(opts = {}) {
|
|
123
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
124
|
-
const json = opts.json ?? false;
|
|
125
|
-
const signals = opts.signals ?? process;
|
|
126
|
-
registerTsResolve(); // 사용자 .ts 의 `.js` 상대 import 를 런타임에 해석
|
|
127
|
-
const layout = resolveDevLayout(cwd);
|
|
128
|
-
const emit = (e) => {
|
|
129
|
-
if (json)
|
|
130
|
-
process.stdout.write(JSON.stringify(e) + '\n');
|
|
131
|
-
else
|
|
132
|
-
process.stdout.write(humanEvent(e) + '\n');
|
|
133
|
-
};
|
|
134
|
-
const deps = {
|
|
135
|
-
layout,
|
|
136
|
-
regenerateTables: generateTablesDts,
|
|
137
|
-
regenerateRoutes: generateRoutesDts,
|
|
138
|
-
watch: watchDir,
|
|
139
|
-
log: emit,
|
|
140
|
-
onError: (err) => process.stderr.write(` ✗ ${err.message}\n`),
|
|
141
|
-
};
|
|
142
|
-
const handle = await startDev(deps);
|
|
143
|
-
await new Promise((resolvePromise) => {
|
|
144
|
-
const stop = () => {
|
|
145
|
-
signals.off('SIGINT', stop);
|
|
146
|
-
signals.off('SIGTERM', stop);
|
|
147
|
-
handle.close();
|
|
148
|
-
resolvePromise();
|
|
149
|
-
};
|
|
150
|
-
signals.on('SIGINT', stop);
|
|
151
|
-
signals.on('SIGTERM', stop);
|
|
152
|
-
});
|
|
153
|
-
}
|
|
96
|
+
// runDevCommand · DevCommandOptions 는 M9-C 에서 commands/dev.ts 로 이동.
|
|
97
|
+
// 이 파일은 .gaon 재생성 코어(startDev · resolveDevLayout)만 유지한다.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
interface KeyUse {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly file: string;
|
|
5
|
+
readonly line: number;
|
|
6
|
+
readonly kind: 'table' | 'getConnection';
|
|
7
|
+
}
|
|
8
|
+
/** gaon.config.ts 를 정적 AST 로 파싱해 db 커넥션 키 목록을 뽑는다. */
|
|
9
|
+
export declare function extractConfigDbKeys(source: string): string[];
|
|
10
|
+
/** 소스에서 table(name, defs, { db: 'X' }) 와 getConnection('X') 호출을 추출한다. */
|
|
11
|
+
export declare function extractKeyUses(cwd: string, file: string, source: string): KeyUse[];
|
|
12
|
+
/** 프로젝트 전체를 훑어 커넥션 키 정합을 검사한다. */
|
|
13
|
+
export declare function checkConnections(cwd: string): Promise<RuleReport>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 커넥션 검사 (M9-E · v0.15 §4.5)
|
|
2
|
+
//
|
|
3
|
+
// 사용자 프로젝트의 커넥션 키 정합을 정적으로 검사한다:
|
|
4
|
+
// 1) domain/schema/*.ts 의 table(..., { db: '<key>' }) 가 지목한 키가
|
|
5
|
+
// gaon.config.ts 의 db 에 등록돼 있는가?
|
|
6
|
+
// 2) 사용자 코드의 getConnection('<key>') 호출이 등록된 키인가?
|
|
7
|
+
// 미등록이면 error — 배포 후 first request 에서 죽는 대신 doctor 에서 잡는다.
|
|
8
|
+
//
|
|
9
|
+
// 방법: gaon.config.ts 를 정적 AST 로 훑어 `db: { <keys>... }` 를 뽑고,
|
|
10
|
+
// 사용자 소스에서 위 두 호출 지점을 훑는다. 'main' 은 스키마 기본값이자
|
|
11
|
+
// 정본 관례 — 등록 키가 하나도 없어도(=config 없음) 'main' 은 항상 허용.
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
import ts from 'typescript';
|
|
16
|
+
/** gaon.config.ts 를 정적 AST 로 파싱해 db 커넥션 키 목록을 뽑는다. */
|
|
17
|
+
export function extractConfigDbKeys(source) {
|
|
18
|
+
const sf = ts.createSourceFile('gaon.config.ts', source, ts.ScriptTarget.ES2022, true);
|
|
19
|
+
const keys = [];
|
|
20
|
+
const visit = (node) => {
|
|
21
|
+
// defineConfig({ db: { main: {...}, legacy: {...} } })
|
|
22
|
+
if (ts.isCallExpression(node) && isDefineConfig(node.expression)) {
|
|
23
|
+
const arg = node.arguments[0];
|
|
24
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
25
|
+
for (const p of arg.properties) {
|
|
26
|
+
if (ts.isPropertyAssignment(p) && propNameText(p.name) === 'db') {
|
|
27
|
+
if (ts.isObjectLiteralExpression(p.initializer)) {
|
|
28
|
+
for (const dp of p.initializer.properties) {
|
|
29
|
+
if (ts.isPropertyAssignment(dp)) {
|
|
30
|
+
const n = propNameText(dp.name);
|
|
31
|
+
if (n)
|
|
32
|
+
keys.push(n);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
ts.forEachChild(node, visit);
|
|
41
|
+
};
|
|
42
|
+
visit(sf);
|
|
43
|
+
return keys;
|
|
44
|
+
}
|
|
45
|
+
function isDefineConfig(e) {
|
|
46
|
+
if (ts.isIdentifier(e) && e.text === 'defineConfig')
|
|
47
|
+
return true;
|
|
48
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'defineConfig')
|
|
49
|
+
return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function propNameText(n) {
|
|
53
|
+
if (ts.isIdentifier(n))
|
|
54
|
+
return n.text;
|
|
55
|
+
if (ts.isStringLiteral(n))
|
|
56
|
+
return n.text;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/** 소스에서 table(name, defs, { db: 'X' }) 와 getConnection('X') 호출을 추출한다. */
|
|
60
|
+
export function extractKeyUses(cwd, file, source) {
|
|
61
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
62
|
+
const uses = [];
|
|
63
|
+
const rel = relative(cwd, file);
|
|
64
|
+
const visit = (node) => {
|
|
65
|
+
if (ts.isCallExpression(node)) {
|
|
66
|
+
const e = node.expression;
|
|
67
|
+
// table(name, defs, { db: '...' })
|
|
68
|
+
if (isNamedCall(e, 'table') && node.arguments.length >= 3) {
|
|
69
|
+
const opts = node.arguments[2];
|
|
70
|
+
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
71
|
+
for (const p of opts.properties) {
|
|
72
|
+
if (ts.isPropertyAssignment(p) && propNameText(p.name) === 'db') {
|
|
73
|
+
const v = p.initializer;
|
|
74
|
+
if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) {
|
|
75
|
+
const { line } = sf.getLineAndCharacterOfPosition(v.getStart(sf));
|
|
76
|
+
uses.push({ key: v.text, file: rel, line: line + 1, kind: 'table' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// getConnection('X')
|
|
83
|
+
if (isNamedCall(e, 'getConnection') && node.arguments.length >= 1) {
|
|
84
|
+
const a = node.arguments[0];
|
|
85
|
+
if (ts.isStringLiteral(a) || ts.isNoSubstitutionTemplateLiteral(a)) {
|
|
86
|
+
const { line } = sf.getLineAndCharacterOfPosition(a.getStart(sf));
|
|
87
|
+
uses.push({ key: a.text, file: rel, line: line + 1, kind: 'getConnection' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
ts.forEachChild(node, visit);
|
|
92
|
+
};
|
|
93
|
+
visit(sf);
|
|
94
|
+
return uses;
|
|
95
|
+
}
|
|
96
|
+
function isNamedCall(e, name) {
|
|
97
|
+
if (ts.isIdentifier(e) && e.text === name)
|
|
98
|
+
return true;
|
|
99
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === name)
|
|
100
|
+
return true;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/** 프로젝트 전체를 훑어 커넥션 키 정합을 검사한다. */
|
|
104
|
+
export async function checkConnections(cwd) {
|
|
105
|
+
const issues = [];
|
|
106
|
+
const registered = new Set(['main']); // main 은 항상 허용(정본 §4.5)
|
|
107
|
+
const configPath = findConfigPath(cwd);
|
|
108
|
+
if (configPath) {
|
|
109
|
+
const src = await readFile(configPath, 'utf8');
|
|
110
|
+
for (const k of extractConfigDbKeys(src))
|
|
111
|
+
registered.add(k);
|
|
112
|
+
}
|
|
113
|
+
const files = [];
|
|
114
|
+
await collectTsFiles(join(cwd, 'domain'), files);
|
|
115
|
+
await collectTsFiles(join(cwd, 'apps'), files);
|
|
116
|
+
await collectTsFiles(join(cwd, 'shared'), files);
|
|
117
|
+
for (const f of files) {
|
|
118
|
+
const src = await readFile(f, 'utf8');
|
|
119
|
+
for (const u of extractKeyUses(cwd, f, src)) {
|
|
120
|
+
if (registered.has(u.key))
|
|
121
|
+
continue;
|
|
122
|
+
const site = u.kind === 'table'
|
|
123
|
+
? `스키마 table(...) 의 { db: '${u.key}' }`
|
|
124
|
+
: `getConnection('${u.key}')`;
|
|
125
|
+
const configHint = configPath
|
|
126
|
+
? `gaon.config.ts 의 db 에 '${u.key}' 커넥션을 등록하세요.`
|
|
127
|
+
: `gaon.config.ts 를 만들고 db.${u.key} 를 등록하세요.`;
|
|
128
|
+
issues.push({
|
|
129
|
+
rule: 'connections',
|
|
130
|
+
level: 'error',
|
|
131
|
+
file: u.file,
|
|
132
|
+
line: u.line,
|
|
133
|
+
message: `커넥션 미등록: ${u.file}:${u.line} · ${site} 는 gaon.config.ts 에 없는 키입니다.\n` +
|
|
134
|
+
`등록된 키: ${[...registered].sort().join(', ')}\n` +
|
|
135
|
+
`→ ${configHint}`,
|
|
136
|
+
detail: { key: u.key, kind: u.kind, registered: [...registered].sort() },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { rule: 'connections', issues };
|
|
141
|
+
}
|
|
142
|
+
function findConfigPath(cwd) {
|
|
143
|
+
for (const nm of ['gaon.config.ts', 'gaon.config.mjs', 'gaon.config.js']) {
|
|
144
|
+
const p = join(cwd, nm);
|
|
145
|
+
if (existsSync(p))
|
|
146
|
+
return p;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
async function collectTsFiles(root, out) {
|
|
151
|
+
let entries;
|
|
152
|
+
try {
|
|
153
|
+
entries = (await readdir(root, { withFileTypes: true }));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const e of entries) {
|
|
159
|
+
const name = e.name;
|
|
160
|
+
if (name === 'node_modules' || name === 'dist' || name === '.gaon')
|
|
161
|
+
continue;
|
|
162
|
+
const full = join(root, name);
|
|
163
|
+
if (e.isDirectory())
|
|
164
|
+
await collectTsFiles(full, out);
|
|
165
|
+
else if (e.isFile() && name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts'))
|
|
166
|
+
out.push(full);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
interface ImportUse {
|
|
3
|
+
readonly line: number;
|
|
4
|
+
readonly typeOnly: boolean;
|
|
5
|
+
readonly resolvedAbs: string;
|
|
6
|
+
}
|
|
7
|
+
/** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점). */
|
|
8
|
+
export declare function extractRelativeImports(file: string, source: string): ImportUse[];
|
|
9
|
+
/** 프로젝트 전체 의존 방향 검사. */
|
|
10
|
+
export declare function checkDependencyDirection(cwd: string): Promise<RuleReport>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 의존 방향 검사 (M9-E · CLAUDE.md 절대 규칙 5)
|
|
2
|
+
//
|
|
3
|
+
// 사용자 프로젝트의 4 규칙(§1-5):
|
|
4
|
+
// 1. app → domain: 허용 (검사 X)
|
|
5
|
+
// 2. domain → app: 금지 → error
|
|
6
|
+
// 3. app → app: 금지 (앱 간 상호 참조 · 자기 앱은 허용) → error
|
|
7
|
+
// 4. app → shared: 허용 / shared → app: 금지 → error /
|
|
8
|
+
// domain → shared: type-only import 만 허용 → error(값 import 시)
|
|
9
|
+
//
|
|
10
|
+
// 방법: 프로젝트 루트에서 domain/·apps/·shared/ 아래 .ts/.vue 를 훑고
|
|
11
|
+
// import 선언의 module specifier 를 상대 경로로 해석 · 규칙 위반 여부 검사.
|
|
12
|
+
// 절대 import(node:xxx · @gaonjs/xxx · npm 패키지)는 대상 아님.
|
|
13
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
14
|
+
import { join, relative, resolve, dirname } from 'node:path';
|
|
15
|
+
import ts from 'typescript';
|
|
16
|
+
/** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점). */
|
|
17
|
+
export function extractRelativeImports(file, source) {
|
|
18
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
19
|
+
const uses = [];
|
|
20
|
+
const baseDir = dirname(file);
|
|
21
|
+
const record = (specifierText, node, typeOnly) => {
|
|
22
|
+
if (!isRelativeSpecifier(specifierText))
|
|
23
|
+
return;
|
|
24
|
+
const abs = resolve(baseDir, specifierText);
|
|
25
|
+
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
26
|
+
uses.push({ line: line + 1, typeOnly, resolvedAbs: abs });
|
|
27
|
+
};
|
|
28
|
+
const visit = (node) => {
|
|
29
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
30
|
+
const typeOnly = !!node.importClause?.isTypeOnly;
|
|
31
|
+
record(node.moduleSpecifier.text, node, typeOnly);
|
|
32
|
+
}
|
|
33
|
+
if (ts.isExportDeclaration(node) &&
|
|
34
|
+
node.moduleSpecifier &&
|
|
35
|
+
ts.isStringLiteral(node.moduleSpecifier)) {
|
|
36
|
+
record(node.moduleSpecifier.text, node, node.isTypeOnly);
|
|
37
|
+
}
|
|
38
|
+
ts.forEachChild(node, visit);
|
|
39
|
+
};
|
|
40
|
+
visit(sf);
|
|
41
|
+
return uses;
|
|
42
|
+
}
|
|
43
|
+
function isRelativeSpecifier(s) {
|
|
44
|
+
return s.startsWith('./') || s.startsWith('../');
|
|
45
|
+
}
|
|
46
|
+
function zoneOf(cwd, absFile) {
|
|
47
|
+
const rel = relative(cwd, absFile).split(/[\\/]/).filter(Boolean);
|
|
48
|
+
if (rel[0] === 'domain')
|
|
49
|
+
return { kind: 'domain' };
|
|
50
|
+
if (rel[0] === 'shared')
|
|
51
|
+
return { kind: 'shared' };
|
|
52
|
+
if (rel[0] === 'apps' && rel[1])
|
|
53
|
+
return { kind: 'app', app: rel[1] };
|
|
54
|
+
return { kind: 'other' };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* import 대상의 확장자를 넓혀 실 존재하는 파일을 하나 잡는다.
|
|
58
|
+
* TS ESM 관례: 소스 코드에서 `.js` 로 import 해도 실제 파일은 `.ts` 다
|
|
59
|
+
* (nodenext moduleResolution). 확장자를 벗기고 후보를 넓힌다.
|
|
60
|
+
*/
|
|
61
|
+
async function resolveTargetFile(abs) {
|
|
62
|
+
const base = stripJsLikeExt(abs);
|
|
63
|
+
const candidates = [
|
|
64
|
+
abs, // 원본 그대로(정확 매치)
|
|
65
|
+
base + '.ts',
|
|
66
|
+
base + '.tsx',
|
|
67
|
+
base + '.js',
|
|
68
|
+
base + '.vue',
|
|
69
|
+
join(base, 'index.ts'),
|
|
70
|
+
join(base, 'index.js'),
|
|
71
|
+
];
|
|
72
|
+
for (const c of candidates) {
|
|
73
|
+
try {
|
|
74
|
+
const s = await stat(c);
|
|
75
|
+
if (s.isFile())
|
|
76
|
+
return c;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// 무시
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function stripJsLikeExt(p) {
|
|
85
|
+
for (const ext of ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx']) {
|
|
86
|
+
if (p.endsWith(ext))
|
|
87
|
+
return p.slice(0, -ext.length);
|
|
88
|
+
}
|
|
89
|
+
return p;
|
|
90
|
+
}
|
|
91
|
+
/** 프로젝트 전체 의존 방향 검사. */
|
|
92
|
+
export async function checkDependencyDirection(cwd) {
|
|
93
|
+
const issues = [];
|
|
94
|
+
const files = [];
|
|
95
|
+
await collectSourceFiles(join(cwd, 'domain'), files);
|
|
96
|
+
await collectSourceFiles(join(cwd, 'apps'), files);
|
|
97
|
+
await collectSourceFiles(join(cwd, 'shared'), files);
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const from = zoneOf(cwd, file);
|
|
100
|
+
if (from.kind === 'other')
|
|
101
|
+
continue;
|
|
102
|
+
const source = await readFile(file, 'utf8');
|
|
103
|
+
const uses = extractRelativeImports(file, source);
|
|
104
|
+
for (const u of uses) {
|
|
105
|
+
const target = await resolveTargetFile(u.resolvedAbs);
|
|
106
|
+
if (!target)
|
|
107
|
+
continue;
|
|
108
|
+
const to = zoneOf(cwd, target);
|
|
109
|
+
const violation = classify(from, to, u.typeOnly);
|
|
110
|
+
if (!violation)
|
|
111
|
+
continue;
|
|
112
|
+
const relFile = relative(cwd, file);
|
|
113
|
+
const relTarget = relative(cwd, target);
|
|
114
|
+
issues.push({
|
|
115
|
+
rule: 'dependency-direction',
|
|
116
|
+
level: 'error',
|
|
117
|
+
file: relFile,
|
|
118
|
+
line: u.line,
|
|
119
|
+
message: `의존 방향 위반: ${relFile}:${u.line} · ${violation.reason}\n` +
|
|
120
|
+
` 대상: ${relTarget}\n` +
|
|
121
|
+
`→ ${violation.fix}`,
|
|
122
|
+
detail: {
|
|
123
|
+
from: from.kind === 'app' ? `app:${from.app}` : from.kind,
|
|
124
|
+
to: to.kind === 'app' ? `app:${to.app}` : to.kind,
|
|
125
|
+
typeOnly: u.typeOnly,
|
|
126
|
+
target: relTarget,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { rule: 'dependency-direction', issues };
|
|
132
|
+
}
|
|
133
|
+
function classify(from, to, typeOnly) {
|
|
134
|
+
// domain → app : 금지
|
|
135
|
+
if (from.kind === 'domain' && to.kind === 'app') {
|
|
136
|
+
return {
|
|
137
|
+
reason: 'domain 은 apps/ 를 import 할 수 없습니다 (역방향).',
|
|
138
|
+
fix: 'domain 은 앱을 몰라야 합니다 · 공통 로직은 shared/ 로 옮기고 앱이 참조하세요.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// domain → shared : type-only 만 허용
|
|
142
|
+
if (from.kind === 'domain' && to.kind === 'shared' && !typeOnly) {
|
|
143
|
+
return {
|
|
144
|
+
reason: 'domain → shared 는 type-only import 만 허용됩니다 (CLAUDE.md §5).',
|
|
145
|
+
fix: `'import type { ... } from ...' 형태로 바꾸거나, 값이 필요하면 로직을 domain/ 안으로 옮기세요.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// app → 다른 app : 금지
|
|
149
|
+
if (from.kind === 'app' && to.kind === 'app' && from.app !== to.app) {
|
|
150
|
+
return {
|
|
151
|
+
reason: `apps/${from.app} 이 apps/${to.app} 를 import 합니다 (앱 간 참조 금지).`,
|
|
152
|
+
fix: '공통 코드는 shared/ 또는 domain/ 으로 옮기고 양쪽 앱이 참조하세요.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// shared → app : 금지
|
|
156
|
+
if (from.kind === 'shared' && to.kind === 'app') {
|
|
157
|
+
return {
|
|
158
|
+
reason: 'shared 는 apps/ 를 import 할 수 없습니다 (역방향).',
|
|
159
|
+
fix: 'shared 는 앱을 몰라야 합니다 · 필요하면 shared 에 인터페이스를 두고 앱에서 구현을 주입하세요.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
async function collectSourceFiles(root, out) {
|
|
165
|
+
let entries;
|
|
166
|
+
try {
|
|
167
|
+
entries = (await readdir(root, { withFileTypes: true }));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const e of entries) {
|
|
173
|
+
const name = e.name;
|
|
174
|
+
if (name === 'node_modules' || name === 'dist' || name === '.gaon')
|
|
175
|
+
continue;
|
|
176
|
+
const full = join(root, name);
|
|
177
|
+
if (e.isDirectory()) {
|
|
178
|
+
await collectSourceFiles(full, out);
|
|
179
|
+
}
|
|
180
|
+
else if (e.isFile()) {
|
|
181
|
+
if (name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts'))
|
|
182
|
+
out.push(full);
|
|
183
|
+
// .vue 는 SFC — 이번 M9-E 는 .ts 만 (Vue 컴파일러 도입은 후속).
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
interface SchemaInfo {
|
|
3
|
+
readonly files: readonly string[];
|
|
4
|
+
readonly tables: readonly string[];
|
|
5
|
+
readonly latestMtimeMs: number;
|
|
6
|
+
}
|
|
7
|
+
/** domain/schema 를 스캔해 table 선언 수·최신 mtime 을 수집한다. */
|
|
8
|
+
export declare function scanSchema(cwd: string): Promise<SchemaInfo>;
|
|
9
|
+
/** 프로젝트에서 마이그 diff 최소 감지를 돌린다. */
|
|
10
|
+
export declare function checkMigrationDiff(cwd: string): Promise<RuleReport>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 마이그 diff 최소 감지 (M9-E · 상세 로직은 M9-D)
|
|
2
|
+
//
|
|
3
|
+
// 배포 전 게이트: 스키마가 편집됐는데 `gaon check`(=`.gaon/tables.d.ts`
|
|
4
|
+
// 재생성)가 뒤처져 있으면 정본과 타입 브리지가 어긋난 상태다. 실제 DB
|
|
5
|
+
// 대비 diff 는 커넥션이 필요하므로 M9-D `gaon db diff` 가 담당한다 —
|
|
6
|
+
// 여기서는 스태틱 게이트(스키마 vs 타입 브리지 신선도)만 잡는다.
|
|
7
|
+
//
|
|
8
|
+
// 검사:
|
|
9
|
+
// 1) domain/schema/*.ts 를 훑어 table(...) 선언을 센다.
|
|
10
|
+
// 2) 스키마 파일의 최신 mtime 을 .gaon/tables.d.ts 의 mtime 과 비교한다.
|
|
11
|
+
// - .gaon/tables.d.ts 없음 · 스키마가 더 최신 → warning
|
|
12
|
+
// 3) 그 외에는 정보성 안내(passed): `gaon db diff` 로 DB 대비 확인.
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
15
|
+
import { join, relative } from 'node:path';
|
|
16
|
+
import ts from 'typescript';
|
|
17
|
+
/** domain/schema 를 스캔해 table 선언 수·최신 mtime 을 수집한다. */
|
|
18
|
+
export async function scanSchema(cwd) {
|
|
19
|
+
const schemaDir = join(cwd, 'domain', 'schema');
|
|
20
|
+
const files = [];
|
|
21
|
+
await collectTsFiles(schemaDir, files);
|
|
22
|
+
const tables = [];
|
|
23
|
+
let latest = 0;
|
|
24
|
+
for (const f of files) {
|
|
25
|
+
const src = await readFile(f, 'utf8');
|
|
26
|
+
tables.push(...extractTableNames(src));
|
|
27
|
+
const st = await stat(f);
|
|
28
|
+
if (st.mtimeMs > latest)
|
|
29
|
+
latest = st.mtimeMs;
|
|
30
|
+
}
|
|
31
|
+
return { files, tables, latestMtimeMs: latest };
|
|
32
|
+
}
|
|
33
|
+
function extractTableNames(source) {
|
|
34
|
+
const sf = ts.createSourceFile('schema.ts', source, ts.ScriptTarget.ES2022, true);
|
|
35
|
+
const names = [];
|
|
36
|
+
const visit = (node) => {
|
|
37
|
+
if (ts.isCallExpression(node) &&
|
|
38
|
+
ts.isIdentifier(node.expression) &&
|
|
39
|
+
node.expression.text === 'table' &&
|
|
40
|
+
node.arguments.length >= 1) {
|
|
41
|
+
const a = node.arguments[0];
|
|
42
|
+
if (ts.isStringLiteral(a) || ts.isNoSubstitutionTemplateLiteral(a))
|
|
43
|
+
names.push(a.text);
|
|
44
|
+
}
|
|
45
|
+
ts.forEachChild(node, visit);
|
|
46
|
+
};
|
|
47
|
+
visit(sf);
|
|
48
|
+
return names;
|
|
49
|
+
}
|
|
50
|
+
/** 프로젝트에서 마이그 diff 최소 감지를 돌린다. */
|
|
51
|
+
export async function checkMigrationDiff(cwd) {
|
|
52
|
+
const info = await scanSchema(cwd);
|
|
53
|
+
if (info.tables.length === 0)
|
|
54
|
+
return { rule: 'migration-diff', issues: [] };
|
|
55
|
+
const tablesDts = join(cwd, '.gaon', 'tables.d.ts');
|
|
56
|
+
const dtsExists = existsSync(tablesDts);
|
|
57
|
+
let dtsMtimeMs = 0;
|
|
58
|
+
if (dtsExists) {
|
|
59
|
+
const st = await stat(tablesDts);
|
|
60
|
+
dtsMtimeMs = st.mtimeMs;
|
|
61
|
+
}
|
|
62
|
+
const issues = [];
|
|
63
|
+
if (!dtsExists) {
|
|
64
|
+
issues.push({
|
|
65
|
+
rule: 'migration-diff',
|
|
66
|
+
level: 'warning',
|
|
67
|
+
file: relative(cwd, join(cwd, 'domain', 'schema')),
|
|
68
|
+
message: `타입 브리지 미생성: .gaon/tables.d.ts 가 없습니다 · 스키마 ${info.tables.length}개 테이블 감지.\n` +
|
|
69
|
+
`→ 'gaon check' 를 실행해 .gaon 을 재생성하고 'gaon db diff' 로 DB 대비를 확인하세요 (상세 diff 는 M9-D).`,
|
|
70
|
+
detail: { tables: [...info.tables], dtsExists },
|
|
71
|
+
});
|
|
72
|
+
return { rule: 'migration-diff', issues };
|
|
73
|
+
}
|
|
74
|
+
if (info.latestMtimeMs > dtsMtimeMs + 1) {
|
|
75
|
+
// 1ms 여유 — 파일시스템 mtime 해상도 대응
|
|
76
|
+
issues.push({
|
|
77
|
+
rule: 'migration-diff',
|
|
78
|
+
level: 'warning',
|
|
79
|
+
file: relative(cwd, tablesDts),
|
|
80
|
+
message: `타입 브리지 뒤처짐: 스키마가 .gaon/tables.d.ts 보다 최신입니다 (${info.tables.length}개 테이블).\n` +
|
|
81
|
+
`→ 'gaon check' 로 재생성한 뒤 'gaon db diff' 로 DB 대비 변경 여부를 확인하세요 (상세 diff 는 M9-D).`,
|
|
82
|
+
detail: {
|
|
83
|
+
tables: [...info.tables],
|
|
84
|
+
schemaLatestMs: info.latestMtimeMs,
|
|
85
|
+
dtsMtimeMs,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { rule: 'migration-diff', issues };
|
|
90
|
+
}
|
|
91
|
+
async function collectTsFiles(root, out) {
|
|
92
|
+
let entries;
|
|
93
|
+
try {
|
|
94
|
+
entries = (await readdir(root, { withFileTypes: true }));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const e of entries) {
|
|
100
|
+
const name = e.name;
|
|
101
|
+
if (name === 'node_modules' || name === 'dist' || name === '.gaon')
|
|
102
|
+
continue;
|
|
103
|
+
const full = join(root, name);
|
|
104
|
+
if (e.isDirectory())
|
|
105
|
+
await collectTsFiles(full, out);
|
|
106
|
+
else if (e.isFile() && name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts'))
|
|
107
|
+
out.push(full);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DoctorCheck, RuleReport } from './types.js';
|
|
2
|
+
/** 소스 하나를 검사해 N+1 위반 목록을 낸다(단위 테스트 진입점). */
|
|
3
|
+
export declare function inspectControllerForNPlusOne(file: string, source: string, cwd: string): DoctorCheck[];
|
|
4
|
+
/** apps/ 를 스캔해 N+1 검사를 돌린다. */
|
|
5
|
+
export declare function checkNPlusOne(cwd: string): Promise<RuleReport>;
|