@gaonjs/cli 0.23.0 → 0.25.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/README.md +10 -3
- package/dist/commands/db.js +2 -2
- package/dist/db.d.ts +7 -2
- package/dist/db.js +30 -17
- package/dist/doctor/async-offload.d.ts +23 -0
- package/dist/doctor/async-offload.js +236 -0
- package/dist/doctor/page-layout-breakpoint.d.ts +8 -0
- package/dist/doctor/page-layout-breakpoint.js +94 -0
- package/dist/doctor/pageprops-destructure.d.ts +5 -0
- package/dist/doctor/pageprops-destructure.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +1 -1
- package/dist/doctor/ui-kit-wiring.js +11 -7
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +17 -2
- package/dist/index.js +2 -1
- package/dist/templates/auth/Dashboard.vue.tpl +12 -11
- package/dist/templates/auth/Login.vue.tpl +18 -16
- package/dist/templates/auth/Signup.vue.tpl +16 -15
- package/dist/templates/project/AGENTS.md.tpl +25 -2
- package/dist/templates/project/agents/async.md.tpl +55 -0
- package/dist/templates/project/agents/data.md.tpl +31 -0
- package/dist/templates/project/agents/frontend.md.tpl +76 -25
- package/dist/templates/project/agents/web.md.tpl +20 -7
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
- package/dist/templates/project/apps/web/style.css.tpl +62 -41
- package/dist/templates/project/tsconfig.json.tpl +5 -1
- package/dist/templates/project/vite.config.ts.tpl +16 -0
- package/dist/templates/ui-kit/EmptyState.vue.tpl +23 -0
- package/dist/templates/ui-kit/PageHeader.vue.tpl +25 -0
- package/dist/templates/ui-kit/PageShell.vue.tpl +27 -0
- package/dist/templates/ui-kit/Pagination.vue.tpl +60 -0
- package/dist/templates/ui-kit/utils.ts.tpl +1 -1
- package/dist/uikit.d.ts +4 -4
- package/dist/uikit.js +56 -22
- package/package.json +6 -6
- package/dist/templates/auth/app.ts.tpl +0 -29
- package/dist/templates/auth/server.ts.tpl +0 -14
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# @gaonjs/cli
|
|
2
2
|
|
|
3
|
-
Gaon CLI
|
|
3
|
+
Gaon CLI — `gaon` 명령. 프로젝트 스캐폴드(`gaon new`), 개발 스택 통합(`gaon dev`),
|
|
4
|
+
제너레이터(`gaon g auth`·scaffold), 마이그레이션(`gaon db …`), 통합 검사
|
|
5
|
+
(`gaon check`), 정적 검사(`gaon doctor` · 20종), 운영 부팅(`gaon serve`). 모든
|
|
6
|
+
명령은 `--json` 출력을 지원합니다.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
이 패키지는 [`gaonjs`](https://www.npmjs.com/package/gaonjs) 파사드에 포함됩니다 —
|
|
9
|
+
`gaonjs` 를 설치하면 `gaon` 명령을 쓸 수 있습니다.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
```bash
|
|
12
|
+
gaon new my-app && cd my-app && gaon dev
|
|
13
|
+
```
|
|
8
14
|
|
|
9
15
|
- 홈페이지 / 문서: https://gaonjs.dev
|
|
16
|
+
- 라이선스: MIT
|
package/dist/commands/db.js
CHANGED
|
@@ -57,8 +57,8 @@ export async function runDbCommand(subcommand, opts = {}) {
|
|
|
57
57
|
return r.exitCode;
|
|
58
58
|
}
|
|
59
59
|
if (subcommand === 'seed') {
|
|
60
|
-
//
|
|
61
|
-
const r = await runDbSeedCommand({ root: cwd, json });
|
|
60
|
+
// 결정 101: seed 도 diff/migrate 와 같은 설정 해석 경로(config → env)를 쓴다.
|
|
61
|
+
const r = await runDbSeedCommand({ root: cwd, json, configPath, dbKey });
|
|
62
62
|
emit(r.text, r.json);
|
|
63
63
|
return r.exitCode;
|
|
64
64
|
}
|
package/dist/db.d.ts
CHANGED
|
@@ -3,8 +3,12 @@ export interface DbSeedOptions {
|
|
|
3
3
|
readonly json?: boolean;
|
|
4
4
|
/** 도메인 루트(domain/ 의 부모). 기본 cwd. */
|
|
5
5
|
readonly root?: string;
|
|
6
|
-
/** DB URL
|
|
6
|
+
/** DB URL 오버라이드(reset 내부 호출 등). 생략 시 config → GAON_DATABASE_URL. */
|
|
7
7
|
readonly databaseUrl?: string;
|
|
8
|
+
/** --config <path> 오버라이드. 없으면 root/gaon.config.ts 관례. */
|
|
9
|
+
readonly configPath?: string;
|
|
10
|
+
/** 커넥션 키(§4.5). 기본 'main'. */
|
|
11
|
+
readonly dbKey?: string;
|
|
8
12
|
}
|
|
9
13
|
/** domain/seed.ts 의 default export 를 읽어 SeedDef 를 돌려준다(없으면 안내 에러). */
|
|
10
14
|
export declare function loadSeed(root: string): Promise<SeedDef>;
|
|
@@ -15,6 +19,7 @@ export interface DbSeedResult {
|
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
21
|
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
18
|
-
* 커넥션
|
|
22
|
+
* 커넥션 해석은 diff·migrate·status·reset 과 같은 경로(resolveDbTarget)를
|
|
23
|
+
* 쓴다(결정 101). 커넥션 정리는 항상 수행한다.
|
|
19
24
|
*/
|
|
20
25
|
export declare function runDbSeedCommand(opts?: DbSeedOptions): Promise<DbSeedResult>;
|
package/dist/db.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// @gaonjs/cli · `gaon db seed` (§7 M8 — domain/seed.ts 실행)
|
|
2
2
|
//
|
|
3
3
|
// 관례: `domain/seed.ts` 의 default export(seed 정의)를 실행한다. 커넥션은
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// diff·migrate·status·reset 과 **같은 해석 경로**(resolveDbTarget)로 연결한다
|
|
5
|
+
// (결정 101). 우선순위: databaseUrl 오버라이드 > gaon.config.ts db.<키> >
|
|
6
|
+
// GAON_DATABASE_URL. 과거엔 seed 만 GAON_DATABASE_URL 만 봐서, config 에
|
|
7
|
+
// db.main 만 선언한 프로젝트는 migrate 는 되는데 seed 만 실패했다(.env 진입점
|
|
8
|
+
// 통일 wave 동형). 모든 명령은 --json 을 함께 낸다(CLAUDE.md §4).
|
|
7
9
|
import { existsSync } from 'node:fs';
|
|
8
10
|
import { join } from 'node:path';
|
|
9
11
|
import { pathToFileURL } from 'node:url';
|
|
10
|
-
import { env, EnvError } from '@gaonjs/core';
|
|
11
12
|
import { createDb, registerConnection, destroyAllConnections, isSeedDef, } from '@gaonjs/data';
|
|
12
13
|
import { registerTsResolve } from './tsResolve.js';
|
|
14
|
+
import { resolveDbTarget } from './db/resolve.js';
|
|
13
15
|
/** DB URL 에서 어댑터를 추정한다(gaon work 와 동일 규칙). */
|
|
14
16
|
function dbConfigFromUrl(url) {
|
|
15
17
|
if (url.startsWith('mysql://') || url.startsWith('mariadb://')) {
|
|
@@ -34,29 +36,40 @@ export async function loadSeed(root) {
|
|
|
34
36
|
}
|
|
35
37
|
return mod.default;
|
|
36
38
|
}
|
|
39
|
+
const SEED_OK = {
|
|
40
|
+
text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
|
|
41
|
+
json: { command: 'seed', ok: true },
|
|
42
|
+
};
|
|
37
43
|
/**
|
|
38
44
|
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
39
|
-
* 커넥션
|
|
45
|
+
* 커넥션 해석은 diff·migrate·status·reset 과 같은 경로(resolveDbTarget)를
|
|
46
|
+
* 쓴다(결정 101). 커넥션 정리는 항상 수행한다.
|
|
40
47
|
*/
|
|
41
48
|
export async function runDbSeedCommand(opts = {}) {
|
|
42
49
|
const root = opts.root ?? process.cwd();
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
const dbKey = opts.dbKey ?? 'main';
|
|
51
|
+
// databaseUrl 명시 오버라이드(reset 내부 호출) — URL 로 직접 커넥션을 세운다.
|
|
52
|
+
if (opts.databaseUrl) {
|
|
53
|
+
const cfg = dbConfigFromUrl(opts.databaseUrl);
|
|
54
|
+
registerConnection(dbKey, createDb(cfg), cfg.adapter);
|
|
55
|
+
try {
|
|
56
|
+
const def = await loadSeed(root);
|
|
57
|
+
await def.run();
|
|
58
|
+
return { exitCode: 0, ...SEED_OK };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await destroyAllConnections();
|
|
62
|
+
}
|
|
48
63
|
}
|
|
49
|
-
|
|
64
|
+
// 그 외 — migrate 와 같은 해석 경로: gaon.config.ts db.<키> → GAON_DATABASE_URL.
|
|
65
|
+
// config 도 env 도 없으면 resolveDbTarget 이 수리 안내 에러를 던진다(§7.5.3).
|
|
66
|
+
const target = await resolveDbTarget({ cwd: root, dbKey, configPath: opts.configPath });
|
|
50
67
|
try {
|
|
51
68
|
const def = await loadSeed(root);
|
|
52
69
|
await def.run();
|
|
53
|
-
return {
|
|
54
|
-
exitCode: 0,
|
|
55
|
-
text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
|
|
56
|
-
json: { command: 'seed', ok: true },
|
|
57
|
-
};
|
|
70
|
+
return { exitCode: 0, ...SEED_OK };
|
|
58
71
|
}
|
|
59
72
|
finally {
|
|
60
|
-
await
|
|
73
|
+
await target.close();
|
|
61
74
|
}
|
|
62
75
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정). */
|
|
3
|
+
export declare function isApiApp(appConfigSource: string): boolean;
|
|
4
|
+
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
5
|
+
export declare function importsMailSdk(source: string): boolean;
|
|
6
|
+
/** 파일이 이미지 처리 라이브러리를 직접 import 하는가(단위 테스트 진입점). */
|
|
7
|
+
export declare function importsImageLib(source: string): boolean;
|
|
8
|
+
/** 액션 본문 텍스트에 리터럴 외부 URL 로의 fetch/axios 호출이 있는가(단위 테스트 진입점). */
|
|
9
|
+
export declare function callsExternalHttp(actionBody: string): boolean;
|
|
10
|
+
/** apps/ 의 컨트롤러를 훑어 요청 경로 인라인 무거운/외부 작업을 경고로 낸다. */
|
|
11
|
+
export declare function checkAsyncOffload(cwd: string): Promise<RuleReport>;
|
|
12
|
+
interface PageAction {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly line: number;
|
|
15
|
+
readonly body: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* controller({ … }) 안에서 **페이지 액션**(return this.render/redirect 을 담는
|
|
19
|
+
* 액션)만 골라 이름·라인·본문 텍스트를 낸다. JSON 액션(반환값=응답 · render/
|
|
20
|
+
* redirect 없음)은 제외한다 — 외부 프록시/집계가 정당한 자리라 C arm 대상 밖.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pageActions(file: string, source: string): PageAction[];
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 요청 경로 인라인 무거운/외부 작업 검출 (결정 102·103 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// 비동기 배치 판단표(결정 102 · AGENTS §3.5 · agents/async.md 서두)의 강제 장치다.
|
|
4
|
+
// 컨트롤러 액션 안에서 "응답에 필요 없는데 무겁거나 느린 일" 을 동기로 하면 웹
|
|
5
|
+
// 응답이 그 무게를 진다 — Node 는 싱글 스레드라 이웃 요청까지 전부 세운다. 정답은
|
|
6
|
+
// `domain/jobs/` 잡으로 빼고 `.later()` 로 발행하는 것(§3.5 · async.md §1).
|
|
7
|
+
//
|
|
8
|
+
// 검출 3종(대표 인라인 패턴):
|
|
9
|
+
// A) 메일 SDK 직접 import (nodemailer·resend·@sendgrid/mail·mailgun) — 컨트롤러는
|
|
10
|
+
// 잡 발행만 (async.md 함정 · async-flow forbidden 과 동일 집합). 앱 종류 무관.
|
|
11
|
+
// B) 이미지/미디어 처리 라이브러리 import (sharp·jimp·gm) — CPU 무거운 작업이
|
|
12
|
+
// 요청 경로에 있다. 앱 종류 무관.
|
|
13
|
+
// C) 외부 HTTP 호출 (fetch/axios 에 **리터럴 `http(s)://` URL**) — 페이지 액션
|
|
14
|
+
// 안에서. 느린/불안정 외부 호출은 응답 시간을 보호하려면 잡으로.
|
|
15
|
+
//
|
|
16
|
+
// 오탐 설계(결정 103 · response-mixing 교훈 "확신 없으면 잡지 않는다"):
|
|
17
|
+
// · **API 앱 제외** — app.config 에 `strategy: 'jwt'` 면 외부 API 호출이 정당
|
|
18
|
+
// (§3 데이터 경로 4종 · 외부 공개 API 앱). 해당 앱은 C(외부 HTTP)를 끈다.
|
|
19
|
+
// · **JSON 액션 제외** — 반환값=응답 액션(E-3)은 프록시/집계일 수 있어 C 제외.
|
|
20
|
+
// C 는 페이지 액션(render/redirect)에만 적용한다.
|
|
21
|
+
// · **리터럴 외부 URL 만** — 변수 URL·상대/내부 경로(`/...`)·`localhost`·
|
|
22
|
+
// `127.0.0.1` 은 정적 판별 불가/내부 호출이라 **잡지 않는다**(빠른 내부 호출
|
|
23
|
+
// 오탐 방지). internal-anchor 가 루트-상대/템플릿리터럴만 잡은 것과 동형.
|
|
24
|
+
// · 주석은 라인 보존 스트립으로 제외 — "이렇게 쓰지 말라" 설명 주석 오탐 방지.
|
|
25
|
+
//
|
|
26
|
+
// 판정은 소스 텍스트 + TS AST(액션 경계)만 본다 — 파일을 실행하지 않는다.
|
|
27
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import { join, relative } from 'node:path';
|
|
30
|
+
import ts from 'typescript';
|
|
31
|
+
// ── 검출 시그니처 ─────────────────────────────────────────────────────
|
|
32
|
+
/** 메일 SDK 모듈 import (컨트롤러 = 잡 발행만 · async.md 함정 · async-flow forbidden 동일 집합). */
|
|
33
|
+
const MAIL_SDK_IMPORT = /import\s+[\s\S]*?\bfrom\s+['"](?:nodemailer|resend|@sendgrid\/mail|mailgun[.\-/][^'"]*|mailgun)['"]/;
|
|
34
|
+
/** 이미지/미디어 처리 라이브러리 import (CPU 무거운 작업). */
|
|
35
|
+
const IMAGE_LIB_IMPORT = /import\s+[\s\S]*?\bfrom\s+['"](?:sharp|jimp|gm)['"]/;
|
|
36
|
+
/**
|
|
37
|
+
* 페이지 액션 본문 안 외부 HTTP 호출 — 리터럴 `http(s)://` URL 만.
|
|
38
|
+
* localhost·127.0.0.1(내부)은 음성 룩어헤드로 제외한다. 변수 URL·상대 경로는
|
|
39
|
+
* 매치하지 않는다(정적 판별 불가 = 잡지 않는다).
|
|
40
|
+
*/
|
|
41
|
+
const EXTERNAL_HTTP = [
|
|
42
|
+
// fetch('https://...') · fetch(`https://...`)
|
|
43
|
+
/\bfetch\s*\(\s*[`'"]https?:\/\/(?!localhost|127\.0\.0\.1)/i,
|
|
44
|
+
// axios('https://...') · axios.get('https://...') · axios({ url: 'https://...' })
|
|
45
|
+
/\baxios\s*(?:\.\s*(?:get|post|put|patch|delete|request|head)\s*)?\(\s*(?:\{[\s\S]*?\burl\s*:\s*)?[`'"]https?:\/\/(?!localhost|127\.0\.0\.1)/i,
|
|
46
|
+
];
|
|
47
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정). */
|
|
48
|
+
export function isApiApp(appConfigSource) {
|
|
49
|
+
return /strategy\s*:\s*['"]jwt['"]/.test(appConfigSource);
|
|
50
|
+
}
|
|
51
|
+
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
52
|
+
export function importsMailSdk(source) {
|
|
53
|
+
return MAIL_SDK_IMPORT.test(stripComments(source));
|
|
54
|
+
}
|
|
55
|
+
/** 파일이 이미지 처리 라이브러리를 직접 import 하는가(단위 테스트 진입점). */
|
|
56
|
+
export function importsImageLib(source) {
|
|
57
|
+
return IMAGE_LIB_IMPORT.test(stripComments(source));
|
|
58
|
+
}
|
|
59
|
+
/** 액션 본문 텍스트에 리터럴 외부 URL 로의 fetch/axios 호출이 있는가(단위 테스트 진입점). */
|
|
60
|
+
export function callsExternalHttp(actionBody) {
|
|
61
|
+
const stripped = stripComments(actionBody);
|
|
62
|
+
return EXTERNAL_HTTP.some((re) => re.test(stripped));
|
|
63
|
+
}
|
|
64
|
+
// ── 검사 본체 ─────────────────────────────────────────────────────────
|
|
65
|
+
/** apps/ 의 컨트롤러를 훑어 요청 경로 인라인 무거운/외부 작업을 경고로 낸다. */
|
|
66
|
+
export async function checkAsyncOffload(cwd) {
|
|
67
|
+
const appsDir = join(cwd, 'apps');
|
|
68
|
+
const issues = [];
|
|
69
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
70
|
+
// 앱 종류 판정 — API 앱이면 외부 HTTP arm 을 끈다(오탐 방지 · 결정 103).
|
|
71
|
+
const acPath = join(appsDir, app, 'app.config.ts');
|
|
72
|
+
const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : '';
|
|
73
|
+
const apiApp = isApiApp(acSource);
|
|
74
|
+
const ctrlDir = join(appsDir, app, 'controllers');
|
|
75
|
+
for (const file of await safeListFiles(ctrlDir)) {
|
|
76
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
|
|
77
|
+
continue;
|
|
78
|
+
const full = join(ctrlDir, file);
|
|
79
|
+
const rel = relative(cwd, full);
|
|
80
|
+
const source = await readFile(full, 'utf8');
|
|
81
|
+
const clean = stripComments(source);
|
|
82
|
+
// A) 메일 SDK import — 앱 종류 무관 경고(컨트롤러는 잡 발행만).
|
|
83
|
+
const mailImport = firstMatch(clean, MAIL_SDK_IMPORT);
|
|
84
|
+
if (mailImport) {
|
|
85
|
+
issues.push(offloadIssue(rel, lineOf(source, mailImport.index), 'mail', app, `메일 SDK 직접 import (${mailImport.text.trim().slice(0, 60)}…)`, '메일 발송은 응답에 필요 없고 실패 시 재시도가 필요한 일'));
|
|
86
|
+
}
|
|
87
|
+
// B) 이미지 처리 라이브러리 import — 앱 종류 무관 경고(CPU 무거운 작업).
|
|
88
|
+
const imgImport = firstMatch(clean, IMAGE_LIB_IMPORT);
|
|
89
|
+
if (imgImport) {
|
|
90
|
+
issues.push(offloadIssue(rel, lineOf(source, imgImport.index), 'image', app, `이미지 처리 라이브러리 import (${imgImport.text.trim().slice(0, 60)}…)`, '이미지 리사이즈/변환은 CPU 무거운 작업 — 루프를 막아 이웃 요청을 세운다'));
|
|
91
|
+
}
|
|
92
|
+
// C) 외부 HTTP — 페이지 액션(render/redirect)에서만 · API 앱 제외.
|
|
93
|
+
if (apiApp)
|
|
94
|
+
continue;
|
|
95
|
+
for (const action of pageActions(full, source)) {
|
|
96
|
+
if (!callsExternalHttp(action.body))
|
|
97
|
+
continue;
|
|
98
|
+
issues.push(offloadIssue(rel, action.line, 'http', app, `페이지 액션 '${action.name}' 안에서 외부 HTTP 호출(리터럴 URL)`, '느리거나 불안정한 외부 호출이 응답 시간에 그대로 실린다'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { rule: 'async-offload', issues };
|
|
103
|
+
}
|
|
104
|
+
/** 공통 경고 메시지 조립 — §7.5.3(에러 = 수리 안내서)대로 "→ 어떻게 고치라" 까지. */
|
|
105
|
+
function offloadIssue(rel, line, kind, app, what, why) {
|
|
106
|
+
const apiHint = kind === 'http'
|
|
107
|
+
? `\n→ 외부 공개 API 앱(모바일·서드파티)이라 외부 호출이 정당하면 apps/${app}/app.config.ts 에 auth: { strategy: 'jwt', ... } 를 선언하세요(이 경고 제외).`
|
|
108
|
+
: '';
|
|
109
|
+
return {
|
|
110
|
+
rule: 'async-offload',
|
|
111
|
+
level: 'warning',
|
|
112
|
+
file: rel,
|
|
113
|
+
line,
|
|
114
|
+
message: `요청 경로 인라인 작업: ${rel}:${line} · ${what}. ${why} — ` +
|
|
115
|
+
`Node 는 싱글 스레드라 무거운/느린 작업 하나가 이웃 요청을 전부 세웁니다(결정 102 판단표).\n` +
|
|
116
|
+
`→ domain/jobs/ 아래 잡으로 빼고 컨트롤러에서는 .later() 로 발행하세요:\n` +
|
|
117
|
+
` // domain/jobs/<camel>.ts — export const <Pascal> = job(async (…) => { …무거운 일… }, { retries: 3 })\n` +
|
|
118
|
+
` // 컨트롤러: await <Pascal>.later(…) (응답은 즉시 반환 · 처리는 gaon work 프로세스)\n` +
|
|
119
|
+
`→ 커밋 뒤에만 나가야 하면 서비스 afterCommit() 또는 아웃박스(agents/async.md §4).` +
|
|
120
|
+
apiHint,
|
|
121
|
+
detail: { app, kind },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* controller({ … }) 안에서 **페이지 액션**(return this.render/redirect 을 담는
|
|
126
|
+
* 액션)만 골라 이름·라인·본문 텍스트를 낸다. JSON 액션(반환값=응답 · render/
|
|
127
|
+
* redirect 없음)은 제외한다 — 외부 프록시/집계가 정당한 자리라 C arm 대상 밖.
|
|
128
|
+
*/
|
|
129
|
+
export function pageActions(file, source) {
|
|
130
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
131
|
+
const out = [];
|
|
132
|
+
const visit = (node) => {
|
|
133
|
+
if (ts.isCallExpression(node) && isControllerCall(node)) {
|
|
134
|
+
const arg = node.arguments[0];
|
|
135
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
136
|
+
for (const prop of arg.properties) {
|
|
137
|
+
const name = actionName(prop);
|
|
138
|
+
const body = actionBodyNode(prop);
|
|
139
|
+
if (!name || !body)
|
|
140
|
+
continue;
|
|
141
|
+
if (!rendersPage(body))
|
|
142
|
+
continue; // JSON/void 액션 제외
|
|
143
|
+
const { line } = sf.getLineAndCharacterOfPosition(prop.getStart(sf));
|
|
144
|
+
out.push({ name, line: line + 1, body: body.getText(sf) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
ts.forEachChild(node, visit);
|
|
149
|
+
};
|
|
150
|
+
visit(sf);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function isControllerCall(node) {
|
|
154
|
+
const e = node.expression;
|
|
155
|
+
if (ts.isIdentifier(e) && e.text === 'controller')
|
|
156
|
+
return true;
|
|
157
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
|
|
158
|
+
return true;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
function actionName(prop) {
|
|
162
|
+
if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
|
|
163
|
+
return prop.name.text;
|
|
164
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
165
|
+
ts.isIdentifier(prop.name) &&
|
|
166
|
+
(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
|
|
167
|
+
return prop.name.text;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
function actionBodyNode(prop) {
|
|
172
|
+
if (ts.isMethodDeclaration(prop))
|
|
173
|
+
return prop.body;
|
|
174
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
175
|
+
if (ts.isArrowFunction(prop.initializer))
|
|
176
|
+
return prop.initializer.body;
|
|
177
|
+
if (ts.isFunctionExpression(prop.initializer))
|
|
178
|
+
return prop.initializer.body;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/** 액션 본문이 this.render/this.redirect 를 하나라도 담는가(= 페이지 액션). */
|
|
183
|
+
function rendersPage(body) {
|
|
184
|
+
let renders = false;
|
|
185
|
+
const visit = (node) => {
|
|
186
|
+
if (renders)
|
|
187
|
+
return;
|
|
188
|
+
// 중첩 함수(콜백)로 내려가지 않는다 — 액션 자신의 응답만 본다.
|
|
189
|
+
if (node !== body &&
|
|
190
|
+
(ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
194
|
+
node.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
195
|
+
(node.name.text === 'render' || node.name.text === 'redirect')) {
|
|
196
|
+
renders = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
ts.forEachChild(node, visit);
|
|
200
|
+
};
|
|
201
|
+
visit(body);
|
|
202
|
+
return renders;
|
|
203
|
+
}
|
|
204
|
+
// ── 텍스트 유틸 ───────────────────────────────────────────────────────
|
|
205
|
+
/** 라인 보존 주석 스트립 — 안티패턴을 설명하는 주석이 오탐을 내지 않도록. */
|
|
206
|
+
function stripComments(source) {
|
|
207
|
+
return source
|
|
208
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
209
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + ' '.repeat(m.length - p1.length));
|
|
210
|
+
}
|
|
211
|
+
function firstMatch(source, re) {
|
|
212
|
+
const m = re.exec(source);
|
|
213
|
+
return m ? { index: m.index, text: m[0] } : undefined;
|
|
214
|
+
}
|
|
215
|
+
/** 문자 오프셋 → 1-기반 라인 번호. */
|
|
216
|
+
function lineOf(source, index) {
|
|
217
|
+
return source.slice(0, index).split('\n').length;
|
|
218
|
+
}
|
|
219
|
+
async function safeListDirs(dir) {
|
|
220
|
+
try {
|
|
221
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
222
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function safeListFiles(dir) {
|
|
229
|
+
try {
|
|
230
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
231
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에서 레이아웃 브레이크포인트 사용 위치(토큰·줄)를 모은다(단위 테스트 진입점). */
|
|
3
|
+
export declare function usesLayoutBreakpoint(source: string): {
|
|
4
|
+
token: string;
|
|
5
|
+
line: number;
|
|
6
|
+
}[];
|
|
7
|
+
/** apps/<앱>/pages 를 훑어 페이지 레이아웃 브레이크포인트 사용을 경고로 낸다. */
|
|
8
|
+
export declare function checkPageLayoutBreakpoint(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 페이지 레이아웃 브레이크포인트 안내 (결정 107)
|
|
2
|
+
//
|
|
3
|
+
// 폭·여백·열 수 같은 레이아웃 반응형은 UI 킷 블록(PageShell·PageHeader …)이
|
|
4
|
+
// 소유한다(결정 105·106·107). 페이지 파일이 레이아웃 브레이크포인트를 루트에서
|
|
5
|
+
// 직접 쓰면 반응형 규칙이 여러 곳에 흩어져, "정답이 하나" 가 무너진다. 이 검사는
|
|
6
|
+
// 그 사용을 **안내 경고(warning)** 로만 낸다 — 강제(error)가 아니다. 킷에 없는
|
|
7
|
+
// 표현이면 Tailwind 유틸을 직접 써도 되므로(탈출구), 오탐을 줄이려 신호가 확실한
|
|
8
|
+
// 레이아웃 유틸(반응형 flex-direction·grid 열 수)만 좁게 잡는다.
|
|
9
|
+
//
|
|
10
|
+
// 대상: apps/<앱>/pages/**/*.vue (페이지 파일만). shared/components/ui 의 킷 블록은
|
|
11
|
+
// 반응형을 소유하므로 검사하지 않는다(그게 이 규칙의 목적).
|
|
12
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
13
|
+
import { join, relative } from 'node:path';
|
|
14
|
+
/**
|
|
15
|
+
* 좁게 잡는 레이아웃 반응형 패턴: 반응형 접두사 + (flex-row/col · grid-cols-N/[).
|
|
16
|
+
* `sm:hidden`·`sm:text-lg`·`sm:px-6` 같은 흔한 반응형(표시·타이포·여백)은 잡지
|
|
17
|
+
* 않는다 — 오탐을 줄이려 페이지 레이아웃을 뒤집는 확실한 신호만 본다.
|
|
18
|
+
*/
|
|
19
|
+
const LAYOUT_BREAKPOINT = /\b(sm|md|lg|xl|2xl):(flex-(?:row|col)(?:-reverse)?|grid-cols-(?:\d+|\[))/;
|
|
20
|
+
/** 소스에서 레이아웃 브레이크포인트 사용 위치(토큰·줄)를 모은다(단위 테스트 진입점). */
|
|
21
|
+
export function usesLayoutBreakpoint(source) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const lines = source.split('\n');
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const m = LAYOUT_BREAKPOINT.exec(lines[i]);
|
|
26
|
+
if (m)
|
|
27
|
+
out.push({ token: m[0], line: i + 1 });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** apps/<앱>/pages 를 훑어 페이지 레이아웃 브레이크포인트 사용을 경고로 낸다. */
|
|
32
|
+
export async function checkPageLayoutBreakpoint(cwd) {
|
|
33
|
+
const appsDir = join(cwd, 'apps');
|
|
34
|
+
const issues = [];
|
|
35
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
36
|
+
const pagesDir = join(appsDir, app, 'pages');
|
|
37
|
+
for (const abs of await walkVueFiles(pagesDir)) {
|
|
38
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
39
|
+
const hits = usesLayoutBreakpoint(source);
|
|
40
|
+
if (hits.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const rel = relative(cwd, abs);
|
|
43
|
+
const first = hits[0];
|
|
44
|
+
issues.push({
|
|
45
|
+
rule: 'page-layout-breakpoint',
|
|
46
|
+
level: 'warning',
|
|
47
|
+
file: rel,
|
|
48
|
+
line: first.line,
|
|
49
|
+
message: `${rel} (line ${first.line})\n` +
|
|
50
|
+
` 페이지가 레이아웃 브레이크포인트('${first.token}'${hits.length > 1 ? ` 외 ${hits.length - 1}건` : ''})를 직접 씁니다.\n` +
|
|
51
|
+
` 반응형(폭·여백·열 수)은 UI 킷 블록이 책임집니다(결정 107).\n` +
|
|
52
|
+
`→ shared/components/ui 의 블록(PageShell·PageHeader 등)으로 감싸 반응형을 킷에 두거나,\n` +
|
|
53
|
+
` 킷에 없는 표현이면 그대로 둬도 됩니다 — 이 경고는 강제가 아닙니다(탈출구 유지).`,
|
|
54
|
+
detail: { token: first.token, count: hits.length },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { rule: 'page-layout-breakpoint', issues };
|
|
59
|
+
}
|
|
60
|
+
/** 디렉터리 트리에서 .vue 파일 절대경로를 모은다(테스트·선언 제외 불필요 — .vue 만). */
|
|
61
|
+
async function walkVueFiles(dir) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const walk = async (d) => {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = (await readdir(d, { withFileTypes: true }));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
const abs = join(d, e.name);
|
|
73
|
+
if (e.isDirectory()) {
|
|
74
|
+
if (e.name === 'node_modules' || e.name === '.gaon' || e.name === 'dist')
|
|
75
|
+
continue;
|
|
76
|
+
await walk(abs);
|
|
77
|
+
}
|
|
78
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
79
|
+
out.push(abs);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await walk(dir);
|
|
84
|
+
return out.sort();
|
|
85
|
+
}
|
|
86
|
+
async function safeListDirs(dir) {
|
|
87
|
+
try {
|
|
88
|
+
const entries = (await readdir(dir, { withFileTypes: true }));
|
|
89
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
|
|
3
|
+
export declare function usesPagePropsDestructure(source: string): boolean;
|
|
4
|
+
/** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
|
|
5
|
+
export declare function checkPagePropsDestructure(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · pageProps() 구조분해 검출 (결정 99 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// `pageProps()` 는 반응형 프록시다 — 같은 페이지로의 리다이렉트/리로드로 서버가
|
|
4
|
+
// 새 props 를 주면(댓글 작성·삭제 등) 재마운트 없이 화면이 즉시 갱신된다. 하지만
|
|
5
|
+
// `const { posts } = pageProps<...>()` 처럼 **구조분해**하면 그 순간 값을 한 번
|
|
6
|
+
// 읽어 반응성이 끊긴다(Vue defineProps 를 구조분해하면 안 되는 것과 같은 함정 ·
|
|
7
|
+
// 첫 실사용 블로그에서 preserveState:false 강제 리로드 우회로 나타난 근본 원인).
|
|
8
|
+
// 이 검사가 그 구조분해를 경고로 잡는다. 판정은 소스 텍스트 기반(주석 제외).
|
|
9
|
+
//
|
|
10
|
+
// 오탐 방지: `const props = pageProps<...>()`(변수 바인딩)는 정상이므로 건드리지
|
|
11
|
+
// 않는다 — 여는 중괄호 `{` 로 시작하는 구조분해 바인딩만 잡는다.
|
|
12
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
13
|
+
import { join, relative } from 'node:path';
|
|
14
|
+
// 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — "구조분해하지
|
|
15
|
+
// 말라"고 설명하는 주석이 오탐을 내지 않도록.
|
|
16
|
+
function stripCommentsKeepLines(source) {
|
|
17
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
18
|
+
return source
|
|
19
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
20
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
21
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
|
|
22
|
+
}
|
|
23
|
+
// `const|let|var { ... } = pageProps` — 구조분해 바인딩의 초기화가 pageProps 호출.
|
|
24
|
+
// 제네릭 인자(`<'web:posts#index'>`)·공백·중첩 중괄호는 허용하되, `{` 로 시작하는
|
|
25
|
+
// 구조분해만 잡는다(변수 바인딩 `const props =` 은 제외).
|
|
26
|
+
const DESTRUCTURE_RE = /\b(?:const|let|var)\s*\{[^}]*\}\s*=\s*pageProps\b/g;
|
|
27
|
+
/** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
|
|
28
|
+
export function usesPagePropsDestructure(source) {
|
|
29
|
+
DESTRUCTURE_RE.lastIndex = 0;
|
|
30
|
+
return DESTRUCTURE_RE.test(stripCommentsKeepLines(source));
|
|
31
|
+
}
|
|
32
|
+
/** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
|
|
33
|
+
export async function checkPagePropsDestructure(cwd) {
|
|
34
|
+
const appsDir = join(cwd, 'apps');
|
|
35
|
+
const issues = [];
|
|
36
|
+
for (const abs of await walkVue(appsDir)) {
|
|
37
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
38
|
+
const stripped = stripCommentsKeepLines(source);
|
|
39
|
+
const rel = relative(cwd, abs);
|
|
40
|
+
for (const m of stripped.matchAll(DESTRUCTURE_RE)) {
|
|
41
|
+
const line = stripped.slice(0, m.index ?? 0).split('\n').length;
|
|
42
|
+
issues.push({
|
|
43
|
+
rule: 'pageprops-destructure',
|
|
44
|
+
level: 'warning',
|
|
45
|
+
file: rel,
|
|
46
|
+
line,
|
|
47
|
+
message: `pageProps() 구조분해 발견: ${rel}:${line} 이 \`const { … } = pageProps(…)\` 로 ` +
|
|
48
|
+
`props 를 구조분해합니다. pageProps() 는 반응형 프록시라, 구조분해하면 그 순간 값을 ` +
|
|
49
|
+
`한 번 읽어 반응성이 끊깁니다 — 같은 페이지로의 리다이렉트/리로드(댓글 작성·삭제 등) ` +
|
|
50
|
+
`후 새 데이터가 화면에 반영되지 않습니다(결정 99).\n` +
|
|
51
|
+
`→ 변수로 받아 속성으로 접근하세요: \`const props = pageProps<'…'>()\` 후 ` +
|
|
52
|
+
`\`props.posts\`. (Vue defineProps 를 구조분해하면 안 되는 것과 같은 이유입니다.)`,
|
|
53
|
+
detail: { file: rel, line },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { rule: 'pageprops-destructure', issues };
|
|
58
|
+
}
|
|
59
|
+
/** apps/ 하위 .vue 절대경로. */
|
|
60
|
+
async function walkVue(dir) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const walk = async (d) => {
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const e of entries) {
|
|
71
|
+
const abs = join(d, e.name);
|
|
72
|
+
if (e.isDirectory()) {
|
|
73
|
+
if (e.name === 'node_modules' || e.name === '.gaon')
|
|
74
|
+
continue;
|
|
75
|
+
await walk(abs);
|
|
76
|
+
}
|
|
77
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
78
|
+
out.push(abs);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await walk(dir);
|
|
83
|
+
return out.sort();
|
|
84
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor';
|
|
1
|
+
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
package/dist/doctor/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 21 검사(전체 목록은 AGENTS §2.2 · doctor.ts `ALL_RULES`)가 모두 이 DoctorCheck
|
|
4
4
|
// 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
|
|
5
5
|
// warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
|
|
6
6
|
// errors.length > 0 이면 fail 로 판단한다.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// @gaonjs/cli · doctor · UI 킷 배선 검사 (결정 76)
|
|
1
|
+
// @gaonjs/cli · doctor · UI 킷 배선 검사 (결정 76 · 결정 105)
|
|
2
2
|
//
|
|
3
|
-
// 앱이 UI 킷 컴포넌트(
|
|
4
|
-
// Tailwind 배선(apps/<앱>/style.css 의 @tailwind +
|
|
5
|
-
// 없으면, UI 킷이 쓰는 유틸 클래스(bg-primary·
|
|
6
|
-
// 컴포넌트가 스타일 없이 렌더된다 — 컴파일은
|
|
7
|
-
// 조용한 런타임 파손이다(결정 74·75·76).
|
|
3
|
+
// 앱이 UI 킷 컴포넌트(@shared/components/ui/* — 결정 105 로 shared 이전)를
|
|
4
|
+
// import 하는데 그 앱에 Tailwind 배선(apps/<앱>/style.css 의 @tailwind +
|
|
5
|
+
// main.ts 의 style.css import)이 없으면, UI 킷이 쓰는 유틸 클래스(bg-primary·
|
|
6
|
+
// rounded-lg …)가 펼쳐지지 않아 컴포넌트가 스타일 없이 렌더된다 — 컴파일은
|
|
7
|
+
// 통과하므로 check 로는 안 잡히는 조용한 런타임 파손이다(결정 74·75·76·105).
|
|
8
|
+
// 이 검사가 그 상태를 경고로 낸다. 킷 위치와 무관하게 import 경로에 남는
|
|
9
|
+
// 'components/ui/' 로 사용처를 잡으므로 @shared alias 도 그대로 감지된다.
|
|
8
10
|
//
|
|
9
11
|
// `gaon new`(web)·`gaon g app` 은 이제 배선을 함께 심으므로 정상 경로에서는
|
|
10
12
|
// 걸리지 않는다. 이 규칙은 배선을 지웠거나 구버전 스캐폴드에서 만든 앱을 잡는
|
|
@@ -44,7 +46,9 @@ export async function checkUiKitWiring(cwd) {
|
|
|
44
46
|
/** 앱 디렉터리를 재귀 스캔해 UI 킷을 import 하는 첫 .vue/.ts 파일(cwd 상대)을 찾는다. */
|
|
45
47
|
async function findUiKitImporter(appDir, cwd) {
|
|
46
48
|
for (const abs of await walkSources(appDir)) {
|
|
47
|
-
//
|
|
49
|
+
// 킷은 이제 shared/ 라 apps/ 스캔엔 안 들어오지만, 결정 105 이전의 앱별
|
|
50
|
+
// 사본(apps/<앱>/components/ui)이 남아 있으면 그 컴포넌트끼리의 상호 import 를
|
|
51
|
+
// 사용처로 오인하지 않도록 제외한다(마이그레이션 과도기 방어).
|
|
48
52
|
if (abs.includes(`${join(appDir, 'components', 'ui')}`))
|
|
49
53
|
continue;
|
|
50
54
|
const source = await readFile(abs, 'utf8').catch(() => '');
|