@gaonjs/cli 0.4.0 → 0.5.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 (47) hide show
  1. package/dist/commands/check.d.ts +31 -0
  2. package/dist/commands/check.js +223 -0
  3. package/dist/commands/console.d.ts +46 -0
  4. package/dist/commands/console.js +129 -0
  5. package/dist/commands/new.d.ts +45 -0
  6. package/dist/commands/new.js +274 -0
  7. package/dist/commands/test.d.ts +11 -0
  8. package/dist/commands/test.js +119 -0
  9. package/dist/doctor/no-auto-import.d.ts +10 -0
  10. package/dist/doctor/no-auto-import.js +158 -0
  11. package/dist/doctor/shared-composable-purity.d.ts +8 -0
  12. package/dist/doctor/shared-composable-purity.js +164 -0
  13. package/dist/doctor/types.d.ts +1 -1
  14. package/dist/doctor/types.js +6 -5
  15. package/dist/doctor.d.ts +2 -0
  16. package/dist/doctor.js +13 -3
  17. package/dist/index.d.ts +4 -1
  18. package/dist/index.js +104 -6
  19. package/dist/templates/index.d.ts +23 -0
  20. package/dist/templates/index.js +66 -0
  21. package/dist/templates/index.ts +85 -0
  22. package/dist/templates/project/.env.example.tpl +18 -0
  23. package/dist/templates/project/.gitignore.tpl +24 -0
  24. package/dist/templates/project/.npmrc.tpl +4 -0
  25. package/dist/templates/project/CLAUDE.md.tpl +119 -0
  26. package/dist/templates/project/apps/web/channels/.gitkeep.tpl +1 -0
  27. package/dist/templates/project/apps/web/components/.gitkeep.tpl +1 -0
  28. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +25 -0
  29. package/dist/templates/project/apps/web/controllers/home.ts.tpl +19 -0
  30. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +43 -0
  31. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +36 -0
  32. package/dist/templates/project/apps/web/routes.ts.tpl +8 -0
  33. package/dist/templates/project/docker-compose.yaml.tpl +73 -0
  34. package/dist/templates/project/domain/events/.gitkeep.tpl +1 -0
  35. package/dist/templates/project/domain/jobs/.gitkeep.tpl +1 -0
  36. package/dist/templates/project/domain/listeners/.gitkeep.tpl +1 -0
  37. package/dist/templates/project/domain/mails/.gitkeep.tpl +1 -0
  38. package/dist/templates/project/domain/models/.gitkeep.tpl +1 -0
  39. package/dist/templates/project/domain/schema/.gitkeep.tpl +1 -0
  40. package/dist/templates/project/domain/services/.gitkeep.tpl +1 -0
  41. package/dist/templates/project/gaon.config.ts.tpl +27 -0
  42. package/dist/templates/project/package.json.tpl +27 -0
  43. package/dist/templates/project/pnpm-workspace.yaml.tpl +11 -0
  44. package/dist/templates/project/shared/components/.gitkeep.tpl +1 -0
  45. package/dist/templates/project/shared/composables/useDebounce.ts.tpl +21 -0
  46. package/dist/templates/project/tsconfig.json.tpl +25 -0
  47. package/package.json +3 -3
@@ -0,0 +1,274 @@
1
+ // @gaonjs/cli · `gaon new <name>` — 프로젝트 스캐폴드 (M9-F).
2
+ //
3
+ // v0.15 §13.5 M9 완료 기준 "설치 후 첫 화면까지 60초" 를 만족시키기 위한
4
+ // One Way. `gaon new demo` → cd demo → `gaon serve` → http://localhost:3000
5
+ // 응답 이 전체 흐름의 목표. 60초 안에 이 왕복이 끝나야 한다.
6
+ //
7
+ // 관례(§2 · errata E-1 · E-3 · E-4 · E-5):
8
+ // · 폴더 배치: apps/<앱>/ + domain/ + shared/ (앱 밖에 domain)
9
+ // · 프론트: Vue + Inertia SPA (`gaonjs/vue`)
10
+ // · 인프라: docker-compose.yaml 5종(pg · redis · nats · mailpit · minio)
11
+ // · 컴포저블·레이아웃: E-5(apps/<앱>/composables + shared/composables, 대칭
12
+ // 구조 · layouts/Default.vue 존재 시 자동 적용)
13
+ // · 자동 import 금지 — 모든 import 는 명시적으로(E-5 §2.4).
14
+ //
15
+ // 옵션은 최소로: --skip-install · --skip-git · --json · --package-manager.
16
+ // The One Way — pnpm workspace monorepo · Vue · Inertia SPA · 5종 인프라.
17
+ //
18
+ // 이 파일은 파일 생성·설치·git init 만 담당하고, argv 파싱과 배선은
19
+ // index.ts 가 통합한다(M9-F 는 편집 X — M1 이 통합 커밋).
20
+ import { existsSync, mkdirSync, readdirSync, writeFileSync, } from 'node:fs';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { readFileSync } from 'node:fs';
25
+ import { renderProjectFiles } from '../templates/index.js';
26
+ /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
27
+ function validateProjectName(name) {
28
+ if (!name)
29
+ throw new Error('프로젝트 이름이 비어 있습니다.');
30
+ if (name.includes('/') || name.includes('\\')) {
31
+ throw new Error(`프로젝트 이름에 '/' 또는 '\\\\' 는 사용할 수 없습니다: ${name}`);
32
+ }
33
+ if (name.includes('..')) {
34
+ throw new Error(`프로젝트 이름에 '..' 는 사용할 수 없습니다: ${name}`);
35
+ }
36
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
37
+ throw new Error(`프로젝트 이름은 영문/숫자/-/_ 로 시작해 같은 문자만 씁니다: ${name}\n` +
38
+ ` → 예: gaon new my-app · gaon new demo`);
39
+ }
40
+ }
41
+ /** 파사드(gaonjs) 패키지의 실 버전을 찾는다 — package.json 을 위로 훑는다. */
42
+ function detectGaonjsVersion() {
43
+ // 가장 안전한 방법: 이 파일 위치를 기준으로 packages/gaonjs/package.json 을
44
+ // 상향 탐색. 개발 트리(src)와 배포 트리(dist) 모두에서 동작한다.
45
+ let dir = dirname(fileURLToPath(import.meta.url));
46
+ for (let i = 0; i < 8; i++) {
47
+ const candidate = join(dir, '..', 'gaonjs', 'package.json');
48
+ if (existsSync(candidate)) {
49
+ try {
50
+ const raw = readFileSync(candidate, 'utf8');
51
+ const pkg = JSON.parse(raw);
52
+ if (typeof pkg.version === 'string')
53
+ return `^${pkg.version}`;
54
+ }
55
+ catch {
56
+ /* fall through */
57
+ }
58
+ }
59
+ const parent = dirname(dir);
60
+ if (parent === dir)
61
+ break;
62
+ dir = parent;
63
+ }
64
+ // 발견 못 하면 최근 스텁 배포 라인의 안전 하한.
65
+ return '^0.5.0';
66
+ }
67
+ /** 대상 폴더가 비어 있는지(생성 대상으로 안전한지) 확인. */
68
+ function isEmptyOrMissing(dir) {
69
+ if (!existsSync(dir))
70
+ return true;
71
+ try {
72
+ const entries = readdirSync(dir);
73
+ return entries.length === 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /** 파일 전체를 디스크에 쓴다. 이미 있으면 예외. */
80
+ function writeAll(root, files) {
81
+ let count = 0;
82
+ for (const f of files) {
83
+ const abs = join(root, f.path);
84
+ mkdirSync(dirname(abs), { recursive: true });
85
+ writeFileSync(abs, f.contents, 'utf8');
86
+ count += 1;
87
+ }
88
+ return count;
89
+ }
90
+ /** 패키지 매니저 install 명령을 동기 실행. 반환값에 종료 코드·시간 포함.
91
+ * json 모드에서는 stdout 을 완전 격리해 JSON 라인이 pnpm progress 로
92
+ * 오염되지 않도록 한다 — 파이프라인 파서 안전 정합. */
93
+ function runInstall(root, pm, json) {
94
+ const args = ['install'];
95
+ const t0 = Date.now();
96
+ // stdio: json 모드에서는 stdout 을 ignore(우리 JSON 을 오염시키지 않게),
97
+ // stderr 는 사용자에게 원인 진단이 필요할 수 있어 inherit.
98
+ // human 모드에서는 모든 스트림을 inherit(사용자가 progress 를 본다).
99
+ const res = spawnSync(pm, args, {
100
+ cwd: root,
101
+ stdio: json ? ['ignore', 'ignore', 'inherit'] : 'inherit',
102
+ env: process.env,
103
+ });
104
+ const ms = Date.now() - t0;
105
+ if (res.error) {
106
+ process.stderr.write(` ✗ ${pm} 을 찾지 못했습니다. → ${pm} 을 설치하거나 --skip-install 로 건너뛰세요.\n`);
107
+ return { code: -1, ms };
108
+ }
109
+ return { code: res.status, ms };
110
+ }
111
+ /** git init + 첫 커밋. git 이 없으면 조용히 스킵(로컬에 git 없이도 스캐폴드는 성공). */
112
+ function runGitInit(root) {
113
+ const opts = { cwd: root, stdio: 'ignore', env: process.env };
114
+ const init = spawnSync('git', ['init', '--initial-branch=main'], opts);
115
+ if (init.error || init.status !== 0)
116
+ return { initialized: false, firstCommit: false };
117
+ spawnSync('git', ['add', '-A'], opts);
118
+ // 첫 커밋 — 사용자 git config 가 없어도 실패하지 않도록 -c 로 임시 identity 주입.
119
+ const commit = spawnSync('git', [
120
+ '-c',
121
+ 'user.email=gaon@gaonjs.dev',
122
+ '-c',
123
+ 'user.name=Gaon',
124
+ 'commit',
125
+ '-m',
126
+ 'chore: gaon new — initial scaffold',
127
+ '--no-gpg-sign',
128
+ ], opts);
129
+ return { initialized: true, firstCommit: commit.status === 0 };
130
+ }
131
+ /**
132
+ * `gaon new <name>` 실행. 파일 생성 → pnpm install → git init 순서.
133
+ * 각 단계는 옵션으로 스킵 가능. 반환값은 프로세스 종료 코드(0=성공).
134
+ */
135
+ export async function runNewCommand(name, opts = {}) {
136
+ const t0 = Date.now();
137
+ const cwd = opts.cwd ?? process.cwd();
138
+ const pm = opts.packageManager ?? 'pnpm';
139
+ const json = opts.json ?? false;
140
+ const emitError = (msg, totalMs) => {
141
+ if (json) {
142
+ const result = {
143
+ ok: false,
144
+ project: name,
145
+ path: resolve(cwd, name),
146
+ filesCreated: 0,
147
+ install: { ran: false, skipped: true, packageManager: pm, durationMs: 0, exitCode: null },
148
+ git: { ran: false, skipped: true, initialized: false, firstCommit: false },
149
+ totalMs,
150
+ error: msg,
151
+ };
152
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
153
+ }
154
+ else {
155
+ process.stderr.write(` ✗ gaon new: ${msg}\n`);
156
+ }
157
+ return 1;
158
+ };
159
+ try {
160
+ validateProjectName(name);
161
+ }
162
+ catch (err) {
163
+ return emitError(err instanceof Error ? err.message : String(err), Date.now() - t0);
164
+ }
165
+ const root = resolve(cwd, name);
166
+ if (!isEmptyOrMissing(root)) {
167
+ return emitError(`대상 폴더가 비어 있지 않습니다: ${root}\n` +
168
+ ` → 다른 이름을 쓰거나 폴더를 비운 뒤 다시 실행하세요.`, Date.now() - t0);
169
+ }
170
+ // 파일 생성 — 실패 시 부분 생성물이 남지 않도록 폴더를 정리하지는 않는다
171
+ // (사용자가 원인 파악 후 rm -rf 로 지우도록). 정상 흐름에서는 문제 없음.
172
+ const gaonjsVersion = opts.gaonjsVersion ?? detectGaonjsVersion();
173
+ const files = renderProjectFiles({ projectName: name, gaonjsVersion });
174
+ let filesCreated = 0;
175
+ try {
176
+ mkdirSync(root, { recursive: true });
177
+ filesCreated = writeAll(root, files);
178
+ }
179
+ catch (err) {
180
+ return emitError(`파일 생성 실패: ${err instanceof Error ? err.message : String(err)}`, Date.now() - t0);
181
+ }
182
+ // pnpm install (또는 npm/yarn) — 스킵 시 안내만.
183
+ const installReport = {
184
+ ran: false,
185
+ skipped: opts.skipInstall === true,
186
+ packageManager: pm,
187
+ durationMs: 0,
188
+ exitCode: null,
189
+ };
190
+ if (!opts.skipInstall) {
191
+ if (!json)
192
+ process.stdout.write(`\n · 의존성 설치 (${pm} install) ...\n`);
193
+ const { code, ms } = runInstall(root, pm, json);
194
+ installReport.ran = true;
195
+ installReport.durationMs = ms;
196
+ installReport.exitCode = code;
197
+ if (code !== 0) {
198
+ // install 실패는 스캐폴드 자체 실패로 취급하지 않는다 — 파일은 이미
199
+ // 만들어졌으므로 사용자가 원인(레지스트리·네트워크) 확인 후 재시도 가능.
200
+ // 종료 코드는 실패로 반환(자동화 파이프라인 정합).
201
+ const total = Date.now() - t0;
202
+ if (json) {
203
+ const result = {
204
+ ok: false,
205
+ project: name,
206
+ path: root,
207
+ filesCreated,
208
+ install: installReport,
209
+ git: { ran: false, skipped: true, initialized: false, firstCommit: false },
210
+ totalMs: total,
211
+ error: `${pm} install 실패 (exit ${code})`,
212
+ };
213
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
214
+ }
215
+ else {
216
+ process.stderr.write(` ✗ ${pm} install 이 실패했습니다 (exit ${code}).\n` +
217
+ ` → 네트워크·레지스트리 상태를 확인한 뒤 ${root} 에서 다시 ${pm} install 을 실행하세요.\n`);
218
+ }
219
+ return 1;
220
+ }
221
+ }
222
+ // git init + 첫 커밋 — 스킵 시 그대로 넘어감.
223
+ const gitReport = { ran: false, skipped: opts.skipGit === true, initialized: false, firstCommit: false };
224
+ if (!opts.skipGit) {
225
+ if (!json)
226
+ process.stdout.write(` · git 저장소 초기화 (git init + 첫 커밋) ...\n`);
227
+ const g = runGitInit(root);
228
+ gitReport.ran = true;
229
+ gitReport.initialized = g.initialized;
230
+ gitReport.firstCommit = g.firstCommit;
231
+ }
232
+ const totalMs = Date.now() - t0;
233
+ if (json) {
234
+ const result = {
235
+ ok: true,
236
+ project: name,
237
+ path: root,
238
+ filesCreated,
239
+ install: installReport,
240
+ git: gitReport,
241
+ totalMs,
242
+ };
243
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
244
+ return 0;
245
+ }
246
+ // 사람 모드 요약 — 다음 단계를 § 방향과 함께 안내(§7.5.3 원칙 정합).
247
+ const lines = [''];
248
+ lines.push(` gaon new ${name} — 스캐폴드 완료 (${filesCreated}개 파일 · ${totalMs}ms)`);
249
+ lines.push('');
250
+ lines.push(` 폴더: ${root}`);
251
+ if (installReport.ran) {
252
+ lines.push(` ${pm} install: ${installReport.exitCode === 0 ? '성공' : '실패'} (${installReport.durationMs}ms)`);
253
+ }
254
+ else {
255
+ lines.push(` ${pm} install: 스킵 (--skip-install)`);
256
+ }
257
+ if (gitReport.ran) {
258
+ lines.push(` git init: ${gitReport.initialized ? '완료' : '실패'} · 첫 커밋: ${gitReport.firstCommit ? '완료' : '실패(수동)'}`);
259
+ }
260
+ else {
261
+ lines.push(` git init: 스킵 (--skip-git)`);
262
+ }
263
+ lines.push('');
264
+ lines.push(' 다음 단계:');
265
+ lines.push(` 1) cd ${name}`);
266
+ lines.push(' 2) cp .env.example .env # 환경 변수 편집');
267
+ lines.push(' 3) gaon dev # Docker + 타입 브리지 + serve 통합');
268
+ lines.push(' 4) http://localhost:3000 # 홈 페이지 확인 (60초 실측 · v0.15 §13.5 M9)');
269
+ lines.push('');
270
+ lines.push(' 문서: https://gaonjs.dev');
271
+ lines.push('');
272
+ process.stdout.write(lines.join('\n') + '\n');
273
+ return 0;
274
+ }
@@ -0,0 +1,11 @@
1
+ export type TestScope = 'unit' | 'integration' | 'all';
2
+ export interface TestCommandOptions {
3
+ readonly cwd?: string;
4
+ readonly json?: boolean;
5
+ readonly scope?: TestScope;
6
+ }
7
+ /**
8
+ * `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
9
+ * pnpm test 는 pnpm 관례상 `pnpm test -- <args>` 로 넘겨야 vitest 까지 도달.
10
+ */
11
+ export declare function runTestCommand(args?: readonly string[], opts?: TestCommandOptions): Promise<number>;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @gaonjs/cli · `gaon test` — vitest wrapper (M9-G · v0.15 §13.5)
3
+ *
4
+ * The One Way — 하나의 명령이 vitest 를 얇게 감싼다. 사용자는
5
+ * `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --unit`
6
+ * 으로 단위만 실행한다. 나머지는 전부 vitest 에 그대로 위임 — 우리가
7
+ * 관례를 재발명하지 않는다.
8
+ *
9
+ * 스코프 필터(§9 실 인프라 관례):
10
+ * --unit *.test.ts (통합 제외)
11
+ * --integration *.integration.test.ts 만
12
+ * (기본) 둘 다 실행
13
+ *
14
+ * 실행 경로 우선순위:
15
+ * 1) 사용자 package.json 의 `test` 스크립트가 있으면 `pnpm test -- <args>`
16
+ * 2) 로컬 node_modules/.bin/vitest 가 있으면 직접 spawn(`run` 모드)
17
+ * 3) 둘 다 없으면 exit 127 + 설치 안내
18
+ *
19
+ * §9 실 인프라 · 목업 X — 실 vitest 프로세스를 spawn 한다.
20
+ * exit code · signal 은 vitest 그대로 propagate.
21
+ */
22
+ import { spawn } from 'node:child_process';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ /** 사용자 프로젝트에 `test` 스크립트가 있는지. */
26
+ function hasTestScript(cwd) {
27
+ const pkgPath = join(cwd, 'package.json');
28
+ if (!existsSync(pkgPath))
29
+ return false;
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
32
+ return typeof pkg.scripts?.test === 'string' && pkg.scripts.test.length > 0;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /**
39
+ * 스코프 → vitest CLI 인자로 매핑. vitest 는 위치 인자를 파일 패턴으로
40
+ * 해석한다(맨 뒤에 붙는 값이 include 필터). --include 플래그는 vitest
41
+ * CLI 에 없다 — 그래서 unit 은 --exclude 로, integration 은 위치 인자
42
+ * 패턴으로 처리한다.
43
+ */
44
+ function scopeArgs(scope) {
45
+ if (scope === 'unit') {
46
+ // 통합 테스트 제외 — vitest 의 --exclude 로 스킵.
47
+ return ['--exclude', '**/*.integration.test.ts'];
48
+ }
49
+ if (scope === 'integration') {
50
+ // integration 만 — 위치 인자 필터(파일 경로 substring 매칭).
51
+ return ['integration.test'];
52
+ }
53
+ return [];
54
+ }
55
+ /**
56
+ * `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
57
+ * pnpm test 는 pnpm 관례상 `pnpm test -- <args>` 로 넘겨야 vitest 까지 도달.
58
+ */
59
+ export async function runTestCommand(args = [], opts = {}) {
60
+ const cwd = opts.cwd ?? process.cwd();
61
+ const scope = opts.scope ?? 'all';
62
+ const json = opts.json ?? false;
63
+ const scopeExtras = scopeArgs(scope);
64
+ const passthrough = [...scopeExtras, ...args];
65
+ let cmd;
66
+ let spawnArgs;
67
+ if (hasTestScript(cwd)) {
68
+ cmd = 'pnpm';
69
+ // pnpm test -- <extras> — `--` 로 뒤 인자를 스크립트에 전달.
70
+ spawnArgs = ['test', ...(passthrough.length ? ['--', ...passthrough] : [])];
71
+ }
72
+ else {
73
+ const vitestBin = join(cwd, 'node_modules', '.bin', 'vitest');
74
+ if (!existsSync(vitestBin)) {
75
+ const msg = `vitest 가 설치돼 있지 않고 package.json 에 "test" 스크립트도 없습니다.\n` +
76
+ `→ pnpm add -D vitest 후 다시 실행하거나, package.json 에 "test": "vitest run" 을 추가하세요.`;
77
+ if (json) {
78
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
79
+ }
80
+ else {
81
+ process.stderr.write(` ✗ ${msg}\n`);
82
+ }
83
+ return 127;
84
+ }
85
+ cmd = vitestBin;
86
+ spawnArgs = ['run', ...passthrough];
87
+ }
88
+ if (json) {
89
+ process.stdout.write(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope }) + '\n');
90
+ }
91
+ else {
92
+ process.stdout.write(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
93
+ }
94
+ const exitCode = await new Promise((resolvePromise) => {
95
+ const child = spawn(cmd, spawnArgs, {
96
+ cwd,
97
+ env: process.env,
98
+ // vitest 컬러 출력·리포터를 그대로 보여주기 위해 stdio 를 상속한다.
99
+ stdio: 'inherit',
100
+ });
101
+ child.on('error', (err) => {
102
+ process.stderr.write(` ✗ gaon test spawn 실패: ${String(err)}\n`);
103
+ resolvePromise(127);
104
+ });
105
+ child.on('close', (code, signal) => {
106
+ if (signal) {
107
+ // SIGINT 등으로 종료된 경우 — 130(SIGINT) 관례.
108
+ resolvePromise(signal === 'SIGINT' ? 130 : 1);
109
+ }
110
+ else {
111
+ resolvePromise(code ?? 1);
112
+ }
113
+ });
114
+ });
115
+ if (json) {
116
+ process.stdout.write(JSON.stringify({ kind: 'exited', exitCode }) + '\n');
117
+ }
118
+ return exitCode;
119
+ }
@@ -0,0 +1,10 @@
1
+ import type { DoctorCheck, RuleReport } from './types.js';
2
+ /**
3
+ * config 파일 하나의 소스에서 자동 import 플러그인 import 를 잡는다
4
+ * (단위 테스트 진입점).
5
+ */
6
+ export declare function inspectConfigForAutoImport(file: string, source: string, cwd: string): DoctorCheck[];
7
+ /** package.json 안의 deps 4종을 훑어 자동 import 플러그인 유무를 낸다. */
8
+ export declare function inspectPackageJson(file: string, source: string, cwd: string): DoctorCheck[];
9
+ /** 프로젝트 루트를 훑어 자동 import 설정·의존을 잡는다. */
10
+ export declare function checkNoAutoImport(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,158 @@
1
+ // @gaonjs/cli · doctor · 자동 import 감지 (M9-E 확장 · errata E-5 §2.4)
2
+ //
3
+ // Nuxt 식 자동 import(컴포넌트·컴포저블을 import 문 없이 사용)는 넣지
4
+ // 않는다. 출처가 코드에 보이지 않는 마법은 AI 가 "이 심볼이 어디서
5
+ // 왔는지"를 추측하게 만들어 v0.15 §1.2 (관례로 추측을 없앤다) 와 정면
6
+ // 충돌한다. 모든 컴포넌트·컴포저블은 명시적으로 import 한다.
7
+ //
8
+ // 검사 대상 (E-5 §2.4 · v0.15 §1.2):
9
+ // 1) 프로젝트 루트의 대표 config 파일 안의 import 선언에서
10
+ // 'unplugin-auto-import' · 'unplugin-vue-components' · 'unimport'
11
+ // 발견 시 error.
12
+ // 2) package.json 의 dependencies · devDependencies · peerDependencies
13
+ // · optionalDependencies 에 위 3종이 있으면 error.
14
+ //
15
+ // 방법: config 는 TS AST 로 import 선언만 훑고 · package.json 은 JSON
16
+ // 파싱 + 라인 검색(에러 메시지에 라인을 붙이기 위해 원문에서 재검색).
17
+ import { existsSync } from 'node:fs';
18
+ import { readFile } from 'node:fs/promises';
19
+ import { join, relative } from 'node:path';
20
+ import ts from 'typescript';
21
+ /** 금지 플러그인 목록 — 자동 import 를 도입하는 대표 3종. */
22
+ const FORBIDDEN_PLUGINS = [
23
+ 'unplugin-auto-import',
24
+ 'unplugin-vue-components',
25
+ 'unimport',
26
+ ];
27
+ /**
28
+ * 검사할 config 파일. Vite/Nuxt/Vue-CLI 관례 파일명을 모두 훑는다.
29
+ * 존재하지 않는 파일은 스킵.
30
+ */
31
+ const CONFIG_FILES = [
32
+ 'vite.config.ts',
33
+ 'vite.config.js',
34
+ 'vite.config.mjs',
35
+ 'nuxt.config.ts',
36
+ 'nuxt.config.js',
37
+ 'vue.config.js',
38
+ 'vue.config.ts',
39
+ ];
40
+ /**
41
+ * config 파일 하나의 소스에서 자동 import 플러그인 import 를 잡는다
42
+ * (단위 테스트 진입점).
43
+ */
44
+ export function inspectConfigForAutoImport(file, source, cwd) {
45
+ const issues = [];
46
+ const rel = relative(cwd, file);
47
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
48
+ const visit = (node) => {
49
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
50
+ const spec = node.moduleSpecifier.text;
51
+ const plugin = matchForbidden(spec);
52
+ if (plugin) {
53
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
54
+ issues.push({
55
+ rule: 'no-auto-import',
56
+ level: 'error',
57
+ file: rel,
58
+ line: line + 1,
59
+ message: `${rel} (line ${line + 1})\n` +
60
+ ` '${plugin}' 발견 · v0.15 §1.2 (관례로 추측 없앤다) 정합 X\n` +
61
+ `→ 자동 import 플러그인 제거 · 명시 import 사용`,
62
+ detail: { plugin, source: 'config-import', module: spec },
63
+ });
64
+ }
65
+ }
66
+ // require('unplugin-...') 형태(CJS config)도 잡는다.
67
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'require') {
68
+ const arg = node.arguments[0];
69
+ if (arg && ts.isStringLiteral(arg)) {
70
+ const plugin = matchForbidden(arg.text);
71
+ if (plugin) {
72
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
73
+ issues.push({
74
+ rule: 'no-auto-import',
75
+ level: 'error',
76
+ file: rel,
77
+ line: line + 1,
78
+ message: `${rel} (line ${line + 1})\n` +
79
+ ` '${plugin}' 발견 · v0.15 §1.2 (관례로 추측 없앤다) 정합 X\n` +
80
+ `→ 자동 import 플러그인 제거 · 명시 import 사용`,
81
+ detail: { plugin, source: 'config-require', module: arg.text },
82
+ });
83
+ }
84
+ }
85
+ }
86
+ ts.forEachChild(node, visit);
87
+ };
88
+ visit(sf);
89
+ return issues;
90
+ }
91
+ /** 지정한 모듈 이름이 금지 목록에 걸리면 해당 플러그인 이름을 낸다. */
92
+ function matchForbidden(spec) {
93
+ return FORBIDDEN_PLUGINS.find((p) => spec === p || spec.startsWith(p + '/'));
94
+ }
95
+ /** package.json 안의 deps 4종을 훑어 자동 import 플러그인 유무를 낸다. */
96
+ export function inspectPackageJson(file, source, cwd) {
97
+ const issues = [];
98
+ const rel = relative(cwd, file);
99
+ let pkg;
100
+ try {
101
+ pkg = JSON.parse(source);
102
+ }
103
+ catch {
104
+ // JSON 파싱 실패 시 이 규칙은 아무 것도 하지 않는다 — 다른 규칙 도구가
105
+ // 잡을 문제(예: gaon check 의 tsconfig 파싱)이지 자동 import 검사의 몫이 아님.
106
+ return [];
107
+ }
108
+ const allDeps = {
109
+ ...(pkg.dependencies ?? {}),
110
+ ...(pkg.devDependencies ?? {}),
111
+ ...(pkg.peerDependencies ?? {}),
112
+ ...(pkg.optionalDependencies ?? {}),
113
+ };
114
+ for (const plugin of FORBIDDEN_PLUGINS) {
115
+ if (!(plugin in allDeps))
116
+ continue;
117
+ const line = findLineForKey(source, plugin);
118
+ const loc = line ? ` (line ${line})` : '';
119
+ issues.push({
120
+ rule: 'no-auto-import',
121
+ level: 'error',
122
+ file: rel,
123
+ line: line,
124
+ message: `${rel}${loc}\n` +
125
+ ` package.json 에 '${plugin}' 발견 · v0.15 §1.2 (관례로 추측 없앤다) 정합 X\n` +
126
+ `→ 자동 import 플러그인 제거 · 명시 import 사용 · 'pnpm remove ${plugin}' 후 vite/nuxt config 도 정리`,
127
+ detail: { plugin, source: 'package-json' },
128
+ });
129
+ }
130
+ return issues;
131
+ }
132
+ /** package.json 원문에서 `"<key>"` 가 처음 나오는 1-기반 라인 번호. */
133
+ function findLineForKey(source, key) {
134
+ const lines = source.split(/\r?\n/);
135
+ const needle = `"${key}"`;
136
+ for (let i = 0; i < lines.length; i++) {
137
+ if (lines[i].includes(needle))
138
+ return i + 1;
139
+ }
140
+ return undefined;
141
+ }
142
+ /** 프로젝트 루트를 훑어 자동 import 설정·의존을 잡는다. */
143
+ export async function checkNoAutoImport(cwd) {
144
+ const issues = [];
145
+ for (const cfg of CONFIG_FILES) {
146
+ const full = join(cwd, cfg);
147
+ if (!existsSync(full))
148
+ continue;
149
+ const src = await readFile(full, 'utf8');
150
+ issues.push(...inspectConfigForAutoImport(full, src, cwd));
151
+ }
152
+ const pkgPath = join(cwd, 'package.json');
153
+ if (existsSync(pkgPath)) {
154
+ const src = await readFile(pkgPath, 'utf8');
155
+ issues.push(...inspectPackageJson(pkgPath, src, cwd));
156
+ }
157
+ return { rule: 'no-auto-import', issues };
158
+ }
@@ -0,0 +1,8 @@
1
+ import type { DoctorCheck, RuleReport } from './types.js';
2
+ /**
3
+ * 단일 shared 컴포저블 소스를 검사해 순수성 위반 목록을 낸다
4
+ * (단위 테스트 진입점).
5
+ */
6
+ export declare function inspectSharedComposable(file: string, source: string, cwd: string): DoctorCheck[];
7
+ /** shared/composables/ 를 훑어 순수성 위반을 모두 낸다. */
8
+ export declare function checkSharedComposablePurity(cwd: string): Promise<RuleReport>;