@gaonjs/cli 0.1.7 → 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 (42) hide show
  1. package/dist/commands/g.d.ts +26 -0
  2. package/dist/commands/g.js +124 -0
  3. package/dist/db.d.ts +20 -0
  4. package/dist/db.js +62 -0
  5. package/dist/doctor/connections.d.ts +14 -0
  6. package/dist/doctor/connections.js +168 -0
  7. package/dist/doctor/dependency-direction.d.ts +11 -0
  8. package/dist/doctor/dependency-direction.js +186 -0
  9. package/dist/doctor/migration-diff.d.ts +11 -0
  10. package/dist/doctor/migration-diff.js +109 -0
  11. package/dist/doctor/n-plus-one.d.ts +5 -0
  12. package/dist/doctor/n-plus-one.js +242 -0
  13. package/dist/doctor/reporter.d.ts +5 -0
  14. package/dist/doctor/reporter.js +28 -0
  15. package/dist/doctor/response-mixing.d.ts +12 -0
  16. package/dist/doctor/response-mixing.js +158 -0
  17. package/dist/doctor/types.d.ts +24 -0
  18. package/dist/doctor/types.js +30 -0
  19. package/dist/doctor.d.ts +39 -0
  20. package/dist/doctor.js +88 -0
  21. package/dist/domain.d.ts +1 -0
  22. package/dist/domain.js +14 -1
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.js +128 -2
  25. package/dist/scaffold/controller.d.ts +12 -0
  26. package/dist/scaffold/controller.js +50 -0
  27. package/dist/scaffold/index.d.ts +20 -0
  28. package/dist/scaffold/index.js +41 -0
  29. package/dist/scaffold/inflect.d.ts +19 -0
  30. package/dist/scaffold/inflect.js +50 -0
  31. package/dist/scaffold/job.d.ts +3 -0
  32. package/dist/scaffold/job.js +46 -0
  33. package/dist/scaffold/model.d.ts +8 -0
  34. package/dist/scaffold/model.js +66 -0
  35. package/dist/scaffold/page.d.ts +7 -0
  36. package/dist/scaffold/page.js +46 -0
  37. package/dist/serve.d.ts +18 -0
  38. package/dist/serve.js +79 -0
  39. package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
  40. package/dist/templates/auth/session.controller.ts.tpl +1 -1
  41. package/dist/work.js +9 -0
  42. package/package.json +8 -5
@@ -0,0 +1,26 @@
1
+ import { type ScaffoldFile, type WriteResult } from '../scaffold/index.js';
2
+ export type GenerateType = 'controller' | 'model' | 'page' | 'job';
3
+ export interface GenerateOptions {
4
+ readonly cwd?: string;
5
+ readonly app?: string;
6
+ readonly overwrite?: boolean;
7
+ readonly json?: boolean;
8
+ }
9
+ export interface GenerateResult {
10
+ readonly type: GenerateType;
11
+ readonly name: string;
12
+ readonly app: string | null;
13
+ readonly write: WriteResult;
14
+ }
15
+ /** argv 에서 옵션을 뽑는다(간단 파서 · runCli 관례와 일치). */
16
+ export declare function parseGenerateArgs(argv: readonly string[]): {
17
+ type: string | undefined;
18
+ name: string | undefined;
19
+ app: string | undefined;
20
+ overwrite: boolean;
21
+ json: boolean;
22
+ };
23
+ /** 타입에 따라 스캐폴드 파일 목록을 만든다. */
24
+ export declare function planScaffold(type: GenerateType, name: string, app: string): ScaffoldFile[];
25
+ /** `gaon g <type> <name>` 실행. exitCode 를 반환한다(0=성공, 1=실패). */
26
+ export declare function runGenerateCommand(type: GenerateType, name: string, opts?: GenerateOptions): number;
@@ -0,0 +1,124 @@
1
+ // @gaonjs/cli · `gaon g <type> <name>` — M9-B 스캐폴드 라우터.
2
+ //
3
+ // 기존 `gaon g auth` (generate.ts · M5) 는 그대로 두고, controller/model/
4
+ // page/job 4종을 이 파일이 받는다. runCli 는 argv[1] 이 'auth' 면
5
+ // runGenerateAuthCommand, 그 외는 runGenerateCommand 로 위임한다.
6
+ //
7
+ // 옵션(공통):
8
+ // --json 결과를 JSON 으로 출력(자동화 · v0.15 §13.5 M9)
9
+ // --app <name> 대상 앱(controller/page 전용 · 기본 'web')
10
+ // --overwrite 기존 파일 덮어쓰기(기본 실패)
11
+ //
12
+ // 파일 위치는 프로젝트 관습(CLAUDE.md §2)을 따른다:
13
+ // controller → apps/<app>/controllers/<plural>.ts
14
+ // model → domain/schema/<camel>.ts + domain/models/<camel>.ts
15
+ // page → apps/<app>/pages/<path>.vue
16
+ // job → domain/jobs/<camel>.ts
17
+ import { controllerScaffold, inflectModel, jobScaffold, modelScaffoldFiles, pageScaffold, writeScaffold, } from '../scaffold/index.js';
18
+ /** argv 에서 옵션을 뽑는다(간단 파서 · runCli 관례와 일치). */
19
+ export function parseGenerateArgs(argv) {
20
+ const type = argv[0];
21
+ // name = 첫 위치 인자(옵션 플래그 제외). --app <v> 도 건너뛴다.
22
+ let name;
23
+ let app;
24
+ let overwrite = false;
25
+ let json = false;
26
+ for (let i = 1; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === '--json') {
29
+ json = true;
30
+ continue;
31
+ }
32
+ if (a === '--overwrite') {
33
+ overwrite = true;
34
+ continue;
35
+ }
36
+ if (a === '--app') {
37
+ app = argv[++i];
38
+ continue;
39
+ }
40
+ if (a?.startsWith('--'))
41
+ continue;
42
+ if (name === undefined)
43
+ name = a;
44
+ }
45
+ return { type, name, app, overwrite, json };
46
+ }
47
+ /** 이름 검증 — 위험 문자(경로 탈출 등) 차단. */
48
+ function validateName(name) {
49
+ if (!name) {
50
+ throw new Error(`이름이 비어 있습니다.`);
51
+ }
52
+ if (name.includes('..') || name.includes('\\')) {
53
+ throw new Error(`이름에 '..' 또는 '\\\\' 는 사용할 수 없습니다: ${name}`);
54
+ }
55
+ // page 는 '/' 를 허용, 나머지는 슬래시 금지(g.ts 가 타입별로 재검사)
56
+ }
57
+ /** 타입에 따라 스캐폴드 파일 목록을 만든다. */
58
+ export function planScaffold(type, name, app) {
59
+ validateName(name);
60
+ if (type === 'page') {
61
+ return [pageScaffold(name, app)];
62
+ }
63
+ if (name.includes('/')) {
64
+ throw new Error(`${type} 이름에 '/' 는 사용할 수 없습니다: ${name}`);
65
+ }
66
+ const names = inflectModel(name);
67
+ if (type === 'controller')
68
+ return [controllerScaffold(names, app)];
69
+ if (type === 'model')
70
+ return modelScaffoldFiles(names);
71
+ if (type === 'job')
72
+ return [jobScaffold(names.pascal)];
73
+ throw new Error(`알 수 없는 제너레이터: ${type}`);
74
+ }
75
+ /** `gaon g <type> <name>` 실행. exitCode 를 반환한다(0=성공, 1=실패). */
76
+ export function runGenerateCommand(type, name, opts = {}) {
77
+ const cwd = opts.cwd ?? process.cwd();
78
+ const app = opts.app ?? 'web';
79
+ let files;
80
+ try {
81
+ files = planScaffold(type, name, app);
82
+ }
83
+ catch (err) {
84
+ const msg = err instanceof Error ? err.message : String(err);
85
+ if (opts.json) {
86
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
87
+ }
88
+ else {
89
+ process.stderr.write(` ✗ gaon g ${type}: ${msg}\n`);
90
+ }
91
+ return 1;
92
+ }
93
+ const write = writeScaffold(cwd, files, { overwrite: opts.overwrite });
94
+ // overwrite 없이 기존 파일이 있으면 실패(사고 방지 · Rails 관례).
95
+ const failed = write.skipped.length > 0;
96
+ const result = {
97
+ type,
98
+ name,
99
+ app: type === 'controller' || type === 'page' ? app : null,
100
+ write,
101
+ };
102
+ if (opts.json) {
103
+ const ok = !failed;
104
+ process.stdout.write(JSON.stringify({ ok, ...result, error: failed ? '기존 파일 존재 (--overwrite 로 덮어쓰기)' : null }, null, 2) +
105
+ '\n');
106
+ return ok ? 0 : 1;
107
+ }
108
+ const lines = [''];
109
+ lines.push(` gaon g ${type} ${name}${result.app ? ` (${result.app} 앱)` : ''}`);
110
+ lines.push('');
111
+ for (const f of write.created)
112
+ lines.push(` + ${f}`);
113
+ for (const f of write.overwritten)
114
+ lines.push(` ~ ${f} (덮어씀)`);
115
+ for (const f of write.skipped)
116
+ lines.push(` ✗ ${f} (이미 있음 — --overwrite 로 덮어쓰기)`);
117
+ lines.push('');
118
+ if (failed) {
119
+ lines.push(' → --overwrite 를 붙이면 기존 파일을 덮어씁니다.');
120
+ lines.push('');
121
+ }
122
+ process.stdout.write(lines.join('\n') + '\n');
123
+ return failed ? 1 : 0;
124
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { type SeedDef } from '@gaonjs/data';
2
+ export interface DbSeedOptions {
3
+ readonly json?: boolean;
4
+ /** 도메인 루트(domain/ 의 부모). 기본 cwd. */
5
+ readonly root?: string;
6
+ /** DB URL 오버라이드. 생략 시 env GAON_DATABASE_URL. */
7
+ readonly databaseUrl?: string;
8
+ }
9
+ /** domain/seed.ts 의 default export 를 읽어 SeedDef 를 돌려준다(없으면 안내 에러). */
10
+ export declare function loadSeed(root: string): Promise<SeedDef>;
11
+ export interface DbSeedResult {
12
+ readonly exitCode: number;
13
+ readonly text: string;
14
+ readonly json: unknown;
15
+ }
16
+ /**
17
+ * `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
18
+ * 커넥션 정리(destroyAllConnections)는 항상 수행한다.
19
+ */
20
+ export declare function runDbSeedCommand(opts?: DbSeedOptions): Promise<DbSeedResult>;
package/dist/db.js ADDED
@@ -0,0 +1,62 @@
1
+ // @gaonjs/cli · `gaon db seed` (§7 M8 — domain/seed.ts 실행)
2
+ //
3
+ // 관례: `domain/seed.ts` 의 default export(seed 정의)를 실행한다. 커넥션은
4
+ // `gaon work` 와 **같은 관례**로 연결한다 — `GAON_DATABASE_URL` 로 main
5
+ // 커넥션을 세운다(§4.5 멀티 커넥션의 gaon.config.ts 전체 로더는 M9 CLI 완성).
6
+ // 모든 명령은 --json 을 함께 낸다(CLAUDE.md §4).
7
+ import { existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { env, EnvError } from '@gaonjs/core';
11
+ import { createDb, registerConnection, destroyAllConnections, isSeedDef, } from '@gaonjs/data';
12
+ import { registerTsResolve } from './tsResolve.js';
13
+ /** DB URL 에서 어댑터를 추정한다(gaon work 와 동일 규칙). */
14
+ function dbConfigFromUrl(url) {
15
+ if (url.startsWith('mysql://') || url.startsWith('mariadb://')) {
16
+ return { adapter: 'mysql', url, poolMax: 4 };
17
+ }
18
+ return { adapter: 'postgres', url, poolMax: 4 };
19
+ }
20
+ /** domain/seed.ts 의 default export 를 읽어 SeedDef 를 돌려준다(없으면 안내 에러). */
21
+ export async function loadSeed(root) {
22
+ const file = join(root, 'domain', 'seed.ts');
23
+ if (!existsSync(file)) {
24
+ throw new Error(`시드 파일이 없습니다: domain/seed.ts\n` +
25
+ ` → domain/seed.ts 를 만들고 default export 로 시드를 선언하세요:\n` +
26
+ ` import { seed } from 'gaonjs/data'\n` +
27
+ ` export default seed(async () => { /* await Model.create(...) */ })`);
28
+ }
29
+ registerTsResolve();
30
+ const mod = (await import(pathToFileURL(file).href));
31
+ if (!isSeedDef(mod.default)) {
32
+ throw new Error(`domain/seed.ts 의 default export 가 시드 정의가 아닙니다.\n` +
33
+ ` → seed(...) 로 감싸 default export 하세요: export default seed(async () => {...})`);
34
+ }
35
+ return mod.default;
36
+ }
37
+ /**
38
+ * `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
39
+ * 커넥션 정리(destroyAllConnections)는 항상 수행한다.
40
+ */
41
+ export async function runDbSeedCommand(opts = {}) {
42
+ const root = opts.root ?? process.cwd();
43
+ const dbUrl = opts.databaseUrl ?? env.optional('GAON_DATABASE_URL');
44
+ if (!dbUrl) {
45
+ throw new EnvError(`환경변수 GAON_DATABASE_URL 가 설정되지 않았습니다(시드에 DB 연결 필요).\n` +
46
+ ` → .env 에 다음 줄을 추가하고 다시 실행하세요:\n` +
47
+ ` GAON_DATABASE_URL=postgres://user:pass@localhost:5432/mydb`);
48
+ }
49
+ registerConnection('main', createDb(dbConfigFromUrl(dbUrl)), dbConfigFromUrl(dbUrl).adapter);
50
+ try {
51
+ const def = await loadSeed(root);
52
+ await def.run();
53
+ return {
54
+ exitCode: 0,
55
+ text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
56
+ json: { command: 'seed', ok: true },
57
+ };
58
+ }
59
+ finally {
60
+ await destroyAllConnections();
61
+ }
62
+ }
@@ -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 {};