@gaonjs/cli 0.10.0 → 0.10.2
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/doctor/column-casing.d.ts +12 -0
- package/dist/doctor/column-casing.js +117 -0
- package/dist/doctor/fixers/index.d.ts +1 -0
- package/dist/doctor/fixers/index.js +23 -0
- package/dist/doctor/fixers/model-filename.d.ts +14 -0
- package/dist/doctor/fixers/model-filename.js +99 -0
- package/dist/doctor/model-filename.d.ts +4 -0
- package/dist/doctor/model-filename.js +70 -0
- package/dist/doctor/page-filename.d.ts +2 -0
- package/dist/doctor/page-filename.js +79 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +3 -2
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +18 -2
- package/dist/index.js +2 -2
- package/dist/scaffold/inflect.d.ts +14 -5
- package/dist/scaffold/inflect.js +14 -2
- package/dist/scaffold/model.js +5 -3
- package/dist/templates/project/AGENTS.md.tpl +9 -2
- package/dist/templates/project/agents/data.md.tpl +34 -1
- package/dist/templates/project/agents/frontend.md.tpl +4 -0
- package/package.json +12 -12
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** snake_case 키를 camelCase 로 정규화(`created_at`→`createdAt`·`author_id`→`authorId`). */
|
|
3
|
+
export declare function expectedColumnName(key: string): string;
|
|
4
|
+
interface SnakeColumn {
|
|
5
|
+
readonly table: string;
|
|
6
|
+
readonly column: string;
|
|
7
|
+
readonly line: number;
|
|
8
|
+
}
|
|
9
|
+
/** 한 스키마 소스에서 스네이크 컬럼 키를 뽑는다(테이블명·라인 포함). */
|
|
10
|
+
export declare function extractSnakeColumns(source: string): SnakeColumn[];
|
|
11
|
+
export declare function checkColumnCasing(cwd: string): Promise<RuleReport>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 컬럼 camelCase 관례 (결정 46 · 2026-07-25)
|
|
2
|
+
//
|
|
3
|
+
// 정본 §네이밍 표 · agents/data.md §1.2(결정 43): 스키마 컬럼명은 camelCase
|
|
4
|
+
// 다(`title`·`authorId`·`createdAt`). 테이블명은 snake_case(`posts_tags`)지만
|
|
5
|
+
// 컬럼은 어느 경우에도 camelCase — FK 는 단수 테이블+`Id`(`postId`), 타임스탬프는
|
|
6
|
+
// `createdAt`/`updatedAt`. 스네이크 컬럼(`created_at`·`author_id`)은 전 fixture·
|
|
7
|
+
// 스캐폴드·examples 에 0건인 지배 관례이나 규칙 표면이 없어 소형 모델이 추측하던
|
|
8
|
+
// 지점(결정 43 · 결정 38 파일명 실패의 컬럼판)이라 검출해 §7.5.3 수리 안내를 준다.
|
|
9
|
+
//
|
|
10
|
+
// 판정 범위: `table('...', { ... })` 2번째 인자(컬럼 정의 객체)의 **최상위 키**만
|
|
11
|
+
// 본다. 키에 `_` 가 있으면(camelCase 는 `_` 를 쓰지 않는다) 스네이크로 판정한다.
|
|
12
|
+
// 테이블명 문자열(1번째 인자)은 대상이 아니다(snake_case 가 정답). `...t.timestamps()`
|
|
13
|
+
// 같은 spread 는 키가 없어 자연히 제외된다.
|
|
14
|
+
//
|
|
15
|
+
// 자동 수정 없음(수동): 컬럼 rename 은 Row 타입·전 쿼리·마이그레이션 참조로
|
|
16
|
+
// 파급돼 기계적 rename 이 안전하지 않다(fixers/index.ts capability note).
|
|
17
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { basename, join, relative } from 'node:path';
|
|
19
|
+
import ts from 'typescript';
|
|
20
|
+
import { toCamel, toPascal } from '../scaffold/inflect.js';
|
|
21
|
+
/** snake_case 키를 camelCase 로 정규화(`created_at`→`createdAt`·`author_id`→`authorId`). */
|
|
22
|
+
export function expectedColumnName(key) {
|
|
23
|
+
return toCamel(toPascal(key));
|
|
24
|
+
}
|
|
25
|
+
/** 한 스키마 소스에서 스네이크 컬럼 키를 뽑는다(테이블명·라인 포함). */
|
|
26
|
+
export function extractSnakeColumns(source) {
|
|
27
|
+
const sf = ts.createSourceFile('schema.ts', source, ts.ScriptTarget.ES2022, true);
|
|
28
|
+
const out = [];
|
|
29
|
+
const visit = (node) => {
|
|
30
|
+
if (ts.isCallExpression(node) &&
|
|
31
|
+
ts.isIdentifier(node.expression) &&
|
|
32
|
+
node.expression.text === 'table' &&
|
|
33
|
+
node.arguments.length >= 2) {
|
|
34
|
+
const nameArg = node.arguments[0];
|
|
35
|
+
const defsArg = node.arguments[1];
|
|
36
|
+
const tableName = ts.isStringLiteral(nameArg) || ts.isNoSubstitutionTemplateLiteral(nameArg)
|
|
37
|
+
? nameArg.text
|
|
38
|
+
: '(?)';
|
|
39
|
+
if (ts.isObjectLiteralExpression(defsArg)) {
|
|
40
|
+
for (const prop of defsArg.properties) {
|
|
41
|
+
// PropertyAssignment(`author_id: t.belongsTo(...)`) · Shorthand · 메서드.
|
|
42
|
+
// Spread(`...t.timestamps()`)·계산된 키는 이름이 없어 건너뛴다.
|
|
43
|
+
const nameNode = (ts.isPropertyAssignment(prop) ||
|
|
44
|
+
ts.isShorthandPropertyAssignment(prop) ||
|
|
45
|
+
ts.isMethodDeclaration(prop)) &&
|
|
46
|
+
prop.name
|
|
47
|
+
? prop.name
|
|
48
|
+
: undefined;
|
|
49
|
+
if (!nameNode)
|
|
50
|
+
continue;
|
|
51
|
+
const key = ts.isIdentifier(nameNode)
|
|
52
|
+
? nameNode.text
|
|
53
|
+
: ts.isStringLiteral(nameNode)
|
|
54
|
+
? nameNode.text
|
|
55
|
+
: undefined;
|
|
56
|
+
if (!key || !key.includes('_'))
|
|
57
|
+
continue;
|
|
58
|
+
const { line } = sf.getLineAndCharacterOfPosition(nameNode.getStart(sf));
|
|
59
|
+
out.push({ table: tableName, column: key, line: line + 1 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
ts.forEachChild(node, visit);
|
|
64
|
+
};
|
|
65
|
+
visit(sf);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
export async function checkColumnCasing(cwd) {
|
|
69
|
+
const schemaDir = join(cwd, 'domain', 'schema');
|
|
70
|
+
const files = [];
|
|
71
|
+
await collectTsFiles(schemaDir, files);
|
|
72
|
+
const issues = [];
|
|
73
|
+
for (const abs of files) {
|
|
74
|
+
const source = await readFile(abs, 'utf8');
|
|
75
|
+
const rel = relative(cwd, abs).split('\\').join('/');
|
|
76
|
+
for (const c of extractSnakeColumns(source)) {
|
|
77
|
+
const expected = expectedColumnName(c.column);
|
|
78
|
+
issues.push({
|
|
79
|
+
rule: 'column-casing',
|
|
80
|
+
level: 'error',
|
|
81
|
+
file: rel,
|
|
82
|
+
line: c.line,
|
|
83
|
+
message: `컬럼명이 관례(§네이밍 · agents/data.md §1.2 · camelCase)와 어긋납니다: ` +
|
|
84
|
+
`테이블 '${c.table}' 의 '${c.column}' (기대 '${expected}').\n` +
|
|
85
|
+
`→ '${rel}' 의 컬럼 키 '${c.column}' 을 '${expected}' 로 바꾸세요. 컬럼은 ` +
|
|
86
|
+
`camelCase 입니다(FK = 단수 테이블+Id 'postId' · 타임스탬프 createdAt/updatedAt). ` +
|
|
87
|
+
`Row 타입·쿼리·마이그레이션의 '${c.column}' 참조도 함께 고칩니다. 테이블명 문자열은 그대로 둡니다(결정 43).`,
|
|
88
|
+
detail: { table: c.table, actual: c.column, expected, filename: basename(abs) },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { rule: 'column-casing', issues };
|
|
93
|
+
}
|
|
94
|
+
/** domain/schema/ 아래 .ts(선언·테스트 제외)를 재귀 수집. schema-filename 과 동일 패턴. */
|
|
95
|
+
async function collectTsFiles(root, out) {
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = (await readdir(root, { withFileTypes: true }));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
const p = join(root, e.name);
|
|
105
|
+
if (e.isDirectory()) {
|
|
106
|
+
await collectTsFiles(p, out);
|
|
107
|
+
}
|
|
108
|
+
else if (e.isFile() &&
|
|
109
|
+
e.name.endsWith('.ts') &&
|
|
110
|
+
!e.name.endsWith('.d.ts') &&
|
|
111
|
+
!e.name.endsWith('.test.ts')) {
|
|
112
|
+
const s = await stat(p);
|
|
113
|
+
if (s.isFile())
|
|
114
|
+
out.push(p);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { Fixer, FixerCapability } from './types.js';
|
|
|
3
3
|
export type { Fixer, FixerCapability, FixerPlan, RewriteFixerPlan, RenameFixerPlan, RefEdit, } from './types.js';
|
|
4
4
|
export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency-direction.js';
|
|
5
5
|
export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
|
|
6
|
+
export { fixModelFilename, rewriteModelImport } from './model-filename.js';
|
|
6
7
|
/**
|
|
7
8
|
* 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
|
|
8
9
|
* 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// runDoctorFix 가 관장 — fixer 는 테스트가 fs 없이 계산만으로 검증된다.
|
|
9
9
|
import { fixDependencyDirection } from './dependency-direction.js';
|
|
10
10
|
import { fixSchemaFilename } from './schema-filename.js';
|
|
11
|
+
import { fixModelFilename } from './model-filename.js';
|
|
11
12
|
export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency-direction.js';
|
|
12
13
|
export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
|
|
14
|
+
export { fixModelFilename, rewriteModelImport } from './model-filename.js';
|
|
13
15
|
/**
|
|
14
16
|
* 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
|
|
15
17
|
* 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
|
|
@@ -17,6 +19,7 @@ export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
|
|
|
17
19
|
export const FIXERS = {
|
|
18
20
|
'dependency-direction': fixDependencyDirection,
|
|
19
21
|
'schema-filename': fixSchemaFilename,
|
|
22
|
+
'model-filename': fixModelFilename,
|
|
20
23
|
};
|
|
21
24
|
/**
|
|
22
25
|
* 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
|
|
@@ -63,4 +66,24 @@ export const FIXER_CAPABILITIES = [
|
|
|
63
66
|
hasFixer: true,
|
|
64
67
|
note: '스키마 파일을 camelCase(테이블명) 로 자동 rename + 이 스키마를 import 하는 곳의 경로를 함께 갱신(결정 38).',
|
|
65
68
|
},
|
|
69
|
+
{
|
|
70
|
+
rule: 'agents-doc-index',
|
|
71
|
+
hasFixer: false,
|
|
72
|
+
note: '수동 · AGENTS 색인(§0)과 agents/ 실 파일을 일치시킵니다(결정 40).',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
rule: 'column-casing',
|
|
76
|
+
hasFixer: false,
|
|
77
|
+
note: '수동 · 컬럼 rename 은 Row 타입·쿼리·마이그레이션 참조로 파급돼 기계적 정정이 안전하지 않습니다(결정 43·46 · camelCase 로 직접 수정).',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
rule: 'model-filename',
|
|
81
|
+
hasFixer: true,
|
|
82
|
+
note: '모델 파일을 PascalCase 로 자동 rename + 이 모델을 import 하는 곳의 경로를 함께 갱신(결정 32·46 · export 심볼명은 불변).',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
rule: 'page-filename',
|
|
86
|
+
hasFixer: false,
|
|
87
|
+
note: "수동 · 페이지는 this.render('...') 문자열·Inertia glob 로 해석돼 import 참조 갱신만으론 부족합니다(결정 32·46 · PascalCase 로 rename 후 render 키 확인).",
|
|
88
|
+
},
|
|
66
89
|
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DoctorCheck } from '../types.js';
|
|
2
|
+
import type { RenameFixerPlan } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* 한 importer 소스에서 `.../models/<oldStem>(.js)?` 참조를 `<newStem>` 로 바꾼다.
|
|
5
|
+
* `models/` 접두 + 따옴표/`.js` 경계로 못박아 오탐을 막는다. 대소문자 그대로
|
|
6
|
+
* (case-sensitive) 매치해 `post` → `Post` 만 잡는다. 바뀐 게 없으면 undefined.
|
|
7
|
+
*/
|
|
8
|
+
export declare function rewriteModelImport(source: string, oldStem: string, newStem: string): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* model-filename 위반 목록을 받아 RenameFixerPlan 을 낸다. 위반마다:
|
|
11
|
+
* 1) 모델 파일을 PascalCase 이름으로 이동(내용 불변).
|
|
12
|
+
* 2) 프로젝트 전체(.ts)에서 그 모델을 import 하는 곳의 경로를 갱신.
|
|
13
|
+
*/
|
|
14
|
+
export declare function fixModelFilename(issues: readonly DoctorCheck[], cwd: string): Promise<readonly RenameFixerPlan[]>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · fixer · 모델 파일명 (결정 46 · 결정 32 · §3.4)
|
|
2
|
+
//
|
|
3
|
+
// 대상 위반: 모델 파일명이 PascalCase 관례와 어긋남(camel·snake·kebab). 예:
|
|
4
|
+
// `domain/models/post.ts` → `domain/models/Post.ts` 로 이름을 바꾸고, 이 모델을
|
|
5
|
+
// import 하는 곳(컨트롤러·서비스·다른 모델)의 경로를 함께 고친다.
|
|
6
|
+
//
|
|
7
|
+
// schema-filename fixer 와 동형(RenameFixerPlan + 참조 갱신). 파일 I/O 는 상위
|
|
8
|
+
// runDoctorFix 가 관장 — 이 함수는 순수 계산만 한다. export 심볼명(`Post`)은
|
|
9
|
+
// 파일명과 무관하게 그대로라 import 경로만 바꾼다.
|
|
10
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
11
|
+
import { join, relative } from 'node:path';
|
|
12
|
+
/** 정규식 리터럴에서 특수문자를 이스케이프한다. */
|
|
13
|
+
function escapeRe(s) {
|
|
14
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 한 importer 소스에서 `.../models/<oldStem>(.js)?` 참조를 `<newStem>` 로 바꾼다.
|
|
18
|
+
* `models/` 접두 + 따옴표/`.js` 경계로 못박아 오탐을 막는다. 대소문자 그대로
|
|
19
|
+
* (case-sensitive) 매치해 `post` → `Post` 만 잡는다. 바뀐 게 없으면 undefined.
|
|
20
|
+
*/
|
|
21
|
+
export function rewriteModelImport(source, oldStem, newStem) {
|
|
22
|
+
const re = new RegExp(`(models/)${escapeRe(oldStem)}(\\.js)?(['"\\\`])`, 'g');
|
|
23
|
+
const after = source.replace(re, `$1${newStem}$2$3`);
|
|
24
|
+
return after === source ? undefined : after;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* model-filename 위반 목록을 받아 RenameFixerPlan 을 낸다. 위반마다:
|
|
28
|
+
* 1) 모델 파일을 PascalCase 이름으로 이동(내용 불변).
|
|
29
|
+
* 2) 프로젝트 전체(.ts)에서 그 모델을 import 하는 곳의 경로를 갱신.
|
|
30
|
+
*/
|
|
31
|
+
export async function fixModelFilename(issues, cwd) {
|
|
32
|
+
const plans = [];
|
|
33
|
+
let sources;
|
|
34
|
+
for (const issue of issues) {
|
|
35
|
+
const detail = issue.detail;
|
|
36
|
+
if (!issue.file || !detail?.actual || !detail?.expected)
|
|
37
|
+
continue;
|
|
38
|
+
const oldStem = detail.actual.replace(/\.ts$/, '');
|
|
39
|
+
const newStem = detail.expected.replace(/\.ts$/, '');
|
|
40
|
+
if (oldStem === newStem)
|
|
41
|
+
continue;
|
|
42
|
+
const oldRel = issue.file;
|
|
43
|
+
const newRel = oldRel.replace(/[^/]+\.ts$/, `${newStem}.ts`);
|
|
44
|
+
const before = await readFile(join(cwd, oldRel), 'utf8');
|
|
45
|
+
if (!sources)
|
|
46
|
+
sources = await collectSources(cwd);
|
|
47
|
+
const refEdits = [];
|
|
48
|
+
for (const s of sources) {
|
|
49
|
+
if (s.rel === oldRel)
|
|
50
|
+
continue;
|
|
51
|
+
const after = rewriteModelImport(s.text, oldStem, newStem);
|
|
52
|
+
if (after !== undefined)
|
|
53
|
+
refEdits.push({ file: s.rel, before: s.text, after });
|
|
54
|
+
}
|
|
55
|
+
const refSummary = refEdits.length > 0
|
|
56
|
+
? `참조 ${refEdits.length}곳 경로 갱신(${refEdits.map((r) => r.file).join(', ')})`
|
|
57
|
+
: '참조 갱신 없음(이 모델을 import 하는 곳을 찾지 못함)';
|
|
58
|
+
plans.push({
|
|
59
|
+
kind: 'rename',
|
|
60
|
+
file: oldRel,
|
|
61
|
+
to: newRel,
|
|
62
|
+
before,
|
|
63
|
+
refEdits,
|
|
64
|
+
summary: `모델 파일명 관례(§3.4 · PascalCase) 정정: '${oldRel}' → '${newRel}'. ${refSummary}.\n` +
|
|
65
|
+
` 근거: 결정 32 · 모델 파일명 = PascalCase. export 심볼명은 그대로 둡니다.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return plans;
|
|
69
|
+
}
|
|
70
|
+
/** 프로젝트 .ts(선언·테스트 제외) 소스를 수집. importer 참조 스캔용. */
|
|
71
|
+
async function collectSources(cwd) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const skip = new Set(['node_modules', '.gaon', 'dist', '.git']);
|
|
74
|
+
async function walk(dir) {
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = (await readdir(dir, { withFileTypes: true }));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
const p = join(dir, e.name);
|
|
84
|
+
if (e.isDirectory()) {
|
|
85
|
+
if (skip.has(e.name))
|
|
86
|
+
continue;
|
|
87
|
+
await walk(p);
|
|
88
|
+
}
|
|
89
|
+
else if (e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) {
|
|
90
|
+
const s = await stat(p);
|
|
91
|
+
if (!s.isFile())
|
|
92
|
+
continue;
|
|
93
|
+
out.push({ rel: relative(cwd, p).split('\\').join('/'), text: await readFile(p, 'utf8') });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await walk(cwd);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 모델 파일명 PascalCase 관례 (결정 46 · 결정 32)
|
|
2
|
+
//
|
|
3
|
+
// 정본 §3.4 파일 네이밍 표: 모델 파일명 = **PascalCase**(`domain/models/Post.ts`).
|
|
4
|
+
// AI 벤치마크 R1 에서 `Post.ts` vs `post.ts` 분열이 실측된 지점이라(결정 32)
|
|
5
|
+
// 표로 고정됐고, 소형 모델이 camel/snake 로 쓰는 경우를 검출해 §7.5.3 rename
|
|
6
|
+
// 안내를 준다. `--fix` = 파일 이름을 PascalCase 로 바꾸고 이 모델을 import 하는
|
|
7
|
+
// 곳(`models/<stem>`)의 경로를 함께 갱신한다(schema-filename fixer 와 동형 ·
|
|
8
|
+
// export 심볼명은 파일명과 무관하게 그대로라 경로만 바뀐다).
|
|
9
|
+
//
|
|
10
|
+
// 판정 범위: `model(` 호출이 있는 파일만 대상으로 삼는다(배럴 `index.ts`·헬퍼
|
|
11
|
+
// 오탐 방지). 파일 stem 이 PascalCase(`^[A-Z][A-Za-z0-9]*$`)가 아니면 위반.
|
|
12
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
13
|
+
import { basename, join, relative } from 'node:path';
|
|
14
|
+
import { toPascal } from '../scaffold/inflect.js';
|
|
15
|
+
/** stem 이 PascalCase(첫 글자 대문자 · `_`·`-` 없음)인가. */
|
|
16
|
+
export function isPascalCase(stem) {
|
|
17
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(stem);
|
|
18
|
+
}
|
|
19
|
+
export async function checkModelFilename(cwd) {
|
|
20
|
+
const modelsDir = join(cwd, 'domain', 'models');
|
|
21
|
+
const files = [];
|
|
22
|
+
await collectTsFiles(modelsDir, files);
|
|
23
|
+
const issues = [];
|
|
24
|
+
for (const abs of files) {
|
|
25
|
+
const source = await readFile(abs, 'utf8');
|
|
26
|
+
if (!/\bmodel\(/.test(source))
|
|
27
|
+
continue; // model() 없음 = 모델 파일 아님 · 건너뜀
|
|
28
|
+
const actualStem = basename(abs).replace(/\.ts$/, '');
|
|
29
|
+
if (isPascalCase(actualStem))
|
|
30
|
+
continue;
|
|
31
|
+
const expected = toPascal(actualStem);
|
|
32
|
+
const rel = relative(cwd, abs).split('\\').join('/');
|
|
33
|
+
const expectedRel = rel.replace(/[^/]+\.ts$/, `${expected}.ts`);
|
|
34
|
+
issues.push({
|
|
35
|
+
rule: 'model-filename',
|
|
36
|
+
level: 'error',
|
|
37
|
+
file: rel,
|
|
38
|
+
message: `모델 파일명이 관례(§3.4 · PascalCase)와 어긋납니다: '${actualStem}.ts' (기대 '${expected}.ts').\n` +
|
|
39
|
+
`→ '${rel}' 를 '${expectedRel}' 로 이름을 바꾸고, 이 모델을 import 하는 ` +
|
|
40
|
+
`컨트롤러·서비스(예: '../models/${expected}.js')의 경로도 함께 고치세요. ` +
|
|
41
|
+
`export 심볼('${expected}')은 파일명과 무관하게 그대로 둡니다. ('gaon doctor --fix' 로 자동 정정 · 결정 32.)`,
|
|
42
|
+
detail: { actual: `${actualStem}.ts`, expected: `${expected}.ts` },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return { rule: 'model-filename', issues };
|
|
46
|
+
}
|
|
47
|
+
/** domain/models/ 아래 .ts(선언·테스트 제외)를 재귀 수집. */
|
|
48
|
+
async function collectTsFiles(root, out) {
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = (await readdir(root, { withFileTypes: true }));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const p = join(root, e.name);
|
|
58
|
+
if (e.isDirectory()) {
|
|
59
|
+
await collectTsFiles(p, out);
|
|
60
|
+
}
|
|
61
|
+
else if (e.isFile() &&
|
|
62
|
+
e.name.endsWith('.ts') &&
|
|
63
|
+
!e.name.endsWith('.d.ts') &&
|
|
64
|
+
!e.name.endsWith('.test.ts')) {
|
|
65
|
+
const s = await stat(p);
|
|
66
|
+
if (s.isFile())
|
|
67
|
+
out.push(p);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 페이지 파일명 PascalCase 관례 (결정 46 · 결정 32)
|
|
2
|
+
//
|
|
3
|
+
// 정본 §3.4 파일 네이밍 표: Vue 페이지 = **PascalCase**(Route 이름 ·
|
|
4
|
+
// `apps/web/pages/Posts/Index.vue`). 폴더 세그먼트도 Route 이름이라 PascalCase
|
|
5
|
+
// 다(`Posts/Index`). 소형 모델이 `posts/index.vue`(소문자)로 쓰는 경우를 검출해
|
|
6
|
+
// §7.5.3 rename 안내를 준다.
|
|
7
|
+
//
|
|
8
|
+
// 자동 수정 없음(수동): 페이지는 컨트롤러 `this.render('Posts/Index')` 문자열과
|
|
9
|
+
// Inertia 클라이언트의 glob 로 해석되지 컴포넌트 import 로 참조되지 않는다 —
|
|
10
|
+
// 파일 이동에 딸린 참조 갱신이 문자열 렌더 키까지 걸쳐 있어 기계적 rename 이
|
|
11
|
+
// 안전하지 않다(fixers/index.ts capability note). 컴포넌트·레이아웃도 PascalCase
|
|
12
|
+
// 지만 이 검사는 결정 46 회부 범위대로 `apps/*/pages/` 만 본다.
|
|
13
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
import { toPascal } from '../scaffold/inflect.js';
|
|
16
|
+
import { isPascalCase } from './model-filename.js';
|
|
17
|
+
export async function checkPageFilename(cwd) {
|
|
18
|
+
const appsDir = join(cwd, 'apps');
|
|
19
|
+
const issues = [];
|
|
20
|
+
let apps;
|
|
21
|
+
try {
|
|
22
|
+
apps = (await readdir(appsDir, { withFileTypes: true }));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { rule: 'page-filename', issues }; // apps/ 없음
|
|
26
|
+
}
|
|
27
|
+
for (const app of apps) {
|
|
28
|
+
if (!app.isDirectory())
|
|
29
|
+
continue;
|
|
30
|
+
const pagesDir = join(appsDir, app.name, 'pages');
|
|
31
|
+
const vueFiles = [];
|
|
32
|
+
await collectVue(pagesDir, vueFiles);
|
|
33
|
+
for (const abs of vueFiles) {
|
|
34
|
+
const rel = relative(cwd, abs).split('\\').join('/');
|
|
35
|
+
// pages/ 아래 경로 세그먼트(폴더 + 파일 stem)가 모두 PascalCase 여야 한다.
|
|
36
|
+
const afterPages = rel.split('/pages/')[1] ?? '';
|
|
37
|
+
const segments = afterPages.replace(/\.vue$/, '').split('/').filter(Boolean);
|
|
38
|
+
const bad = segments.filter((s) => !isPascalCase(s));
|
|
39
|
+
if (bad.length === 0)
|
|
40
|
+
continue;
|
|
41
|
+
const fixedRel = rel.replace(/pages\/(.+)\.vue$/, (_m, p) => `pages/${String(p)
|
|
42
|
+
.split('/')
|
|
43
|
+
.map((s) => (isPascalCase(s) ? s : toPascal(s)))
|
|
44
|
+
.join('/')}.vue`);
|
|
45
|
+
issues.push({
|
|
46
|
+
rule: 'page-filename',
|
|
47
|
+
level: 'error',
|
|
48
|
+
file: rel,
|
|
49
|
+
message: `Vue 페이지 파일명이 관례(§3.4 · PascalCase · Route 이름)와 어긋납니다: ` +
|
|
50
|
+
`세그먼트 ${bad.map((b) => `'${b}'`).join(', ')} (기대 경로 '${fixedRel}').\n` +
|
|
51
|
+
`→ '${rel}' 를 '${fixedRel}' 로 이름을 바꾸고, 이 페이지를 렌더하는 컨트롤러의 ` +
|
|
52
|
+
`this.render('...') 문자열도 새 경로로 맞추세요. (결정 32 · 파일 네이밍 표.)`,
|
|
53
|
+
detail: { actual: rel, expected: fixedRel, badSegments: bad },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { rule: 'page-filename', issues };
|
|
58
|
+
}
|
|
59
|
+
/** pages/ 아래 .vue 를 재귀 수집. */
|
|
60
|
+
async function collectVue(root, out) {
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = (await readdir(root, { withFileTypes: true }));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const p = join(root, e.name);
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
await collectVue(p, out);
|
|
72
|
+
}
|
|
73
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
74
|
+
const s = await stat(p);
|
|
75
|
+
if (s.isFile())
|
|
76
|
+
out.push(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
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';
|
|
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';
|
|
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,7 +1,8 @@
|
|
|
1
1
|
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// · migration-diff · shared-composable-purity · no-auto-import
|
|
3
|
+
// 12 검사(response-mixing · n-plus-one · dependency-direction · connections
|
|
4
|
+
// · migration-diff · shared-composable-purity · no-auto-import · schema-filename
|
|
5
|
+
// · agents-doc-index · column-casing · model-filename · page-filename)가 모두
|
|
5
6
|
// 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
|
|
6
7
|
// warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
|
|
7
8
|
// errors.length > 0 이면 fail 로 판단한다.
|
package/dist/doctor.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/
|
|
|
10
10
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
11
11
|
export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
|
|
12
12
|
export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
|
|
13
|
+
export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './doctor/column-casing.js';
|
|
14
|
+
export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
|
|
15
|
+
export { checkPageFilename } from './doctor/page-filename.js';
|
|
13
16
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
14
17
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
15
18
|
export interface DoctorCommandOptions {
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 12 검사를 조립한다:
|
|
5
5
|
* 1) response-mixing (errata E-3 §C · 라이브)
|
|
6
6
|
* 2) n-plus-one (errata E-4 (e))
|
|
7
7
|
* 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* 6) shared-composable-purity (errata E-5 §2.2 · 결정 25)
|
|
11
11
|
* 7) no-auto-import (errata E-5 §2.4 · v0.15 §1.2)
|
|
12
12
|
* 8) schema-filename (§1.1 · 결정 38 · 스키마 파일명 camelCase)
|
|
13
|
+
* 9) agents-doc-index (§0 색인 ↔ agents/ 실 파일 · 결정 40)
|
|
14
|
+
* 10) column-casing (§네이밍 · 결정 43·46 · 컬럼 camelCase)
|
|
15
|
+
* 11) model-filename (§3.4 · 결정 32·46 · 모델 파일명 PascalCase)
|
|
16
|
+
* 12) page-filename (§3.4 · 결정 32·46 · Vue 페이지 파일명 PascalCase)
|
|
13
17
|
*
|
|
14
18
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
15
19
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -31,6 +35,9 @@ import { checkSharedComposablePurity } from './doctor/shared-composable-purity.j
|
|
|
31
35
|
import { checkNoAutoImport } from './doctor/no-auto-import.js';
|
|
32
36
|
import { checkSchemaFilename } from './doctor/schema-filename.js';
|
|
33
37
|
import { checkAgentsDocIndex } from './doctor/agents-doc-index.js';
|
|
38
|
+
import { checkColumnCasing } from './doctor/column-casing.js';
|
|
39
|
+
import { checkModelFilename } from './doctor/model-filename.js';
|
|
40
|
+
import { checkPageFilename } from './doctor/page-filename.js';
|
|
34
41
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
35
42
|
import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
36
43
|
import { makeResult, } from './doctor/types.js';
|
|
@@ -44,10 +51,13 @@ export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/
|
|
|
44
51
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
45
52
|
export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
|
|
46
53
|
export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
|
|
54
|
+
export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './doctor/column-casing.js';
|
|
55
|
+
export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
|
|
56
|
+
export { checkPageFilename } from './doctor/page-filename.js';
|
|
47
57
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
48
58
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
49
59
|
/**
|
|
50
|
-
* 실행할 검사 이름. 지정 없음(undefined) =
|
|
60
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 12개 모두.
|
|
51
61
|
*/
|
|
52
62
|
const ALL_RULES = [
|
|
53
63
|
'response-mixing',
|
|
@@ -59,6 +69,9 @@ const ALL_RULES = [
|
|
|
59
69
|
'no-auto-import',
|
|
60
70
|
'schema-filename',
|
|
61
71
|
'agents-doc-index',
|
|
72
|
+
'column-casing',
|
|
73
|
+
'model-filename',
|
|
74
|
+
'page-filename',
|
|
62
75
|
];
|
|
63
76
|
const CHECKERS = {
|
|
64
77
|
'response-mixing': checkResponseMixing,
|
|
@@ -70,6 +83,9 @@ const CHECKERS = {
|
|
|
70
83
|
'no-auto-import': checkNoAutoImport,
|
|
71
84
|
'schema-filename': checkSchemaFilename,
|
|
72
85
|
'agents-doc-index': checkAgentsDocIndex,
|
|
86
|
+
'column-casing': checkColumnCasing,
|
|
87
|
+
'model-filename': checkModelFilename,
|
|
88
|
+
'page-filename': checkPageFilename,
|
|
73
89
|
};
|
|
74
90
|
/**
|
|
75
91
|
* `gaon doctor` 진입점 (M9-E · 확장 · M9-E-Fix 하드닝).
|
package/dist/index.js
CHANGED
|
@@ -98,7 +98,7 @@ function renderHelp(version = VERSION) {
|
|
|
98
98
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
99
99
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
100
100
|
" gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
|
|
101
|
-
" gaon doctor 정적 검사 (
|
|
101
|
+
" gaon doctor 정적 검사 (12 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례)",
|
|
102
102
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
103
103
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
104
104
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -223,7 +223,7 @@ export function runCli(argv, opts = {}) {
|
|
|
223
223
|
});
|
|
224
224
|
return;
|
|
225
225
|
}
|
|
226
|
-
// `gaon doctor` — 정적 검사(M9-E ·
|
|
226
|
+
// `gaon doctor` — 정적 검사(M9-E · 12 검사). --check=<이름>[,<이름>...] 로
|
|
227
227
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
228
228
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
|
229
229
|
if (argv[0] === "doctor") {
|
|
@@ -2,18 +2,27 @@
|
|
|
2
2
|
export declare function toCamel(s: string): string;
|
|
3
3
|
/** 카멜/스네이크/케밥 → 파스칼케이스 (`post` → `Post`, `send_email` → `SendEmail`). */
|
|
4
4
|
export declare function toPascal(s: string): string;
|
|
5
|
+
/** 카멜/파스칼 → snake_case (`postTags` → `post_tags`, `AuditLogs` → `audit_logs`). */
|
|
6
|
+
export declare function toSnake(s: string): string;
|
|
5
7
|
/** 단순 단수화 — 끝의 s 하나만 벗긴다 ('users' → 'user' · 'ss' 는 유지). */
|
|
6
8
|
export declare function singularize(s: string): string;
|
|
7
9
|
/** 단순 복수화 — 끝에 s 를 붙인다 ('user' → 'users' · 이미 s 로 끝나면 유지). */
|
|
8
10
|
export declare function pluralize(s: string): string;
|
|
9
|
-
/** 모델 이름 · 표준 변형 묶음. 스캐폴드가
|
|
11
|
+
/** 모델 이름 · 표준 변형 묶음. 스캐폴드가 파일명·심볼명·테이블명에 쓴다. */
|
|
10
12
|
export interface ModelNames {
|
|
11
|
-
/** 파스칼 단수 — 클래스/const 명 (`Post`). */
|
|
13
|
+
/** 파스칼 단수 — 클래스/const 명 (`Post` · `PostTag`). */
|
|
12
14
|
readonly pascal: string;
|
|
13
|
-
/** 카멜 단수 —
|
|
15
|
+
/** 카멜 단수 — 변수명 (`post` · `postTag`). */
|
|
14
16
|
readonly camel: string;
|
|
15
|
-
/** 카멜 복수 —
|
|
17
|
+
/** 카멜 복수 — 스키마 파일 stem·export 심볼 (`posts` · `postTags`). */
|
|
16
18
|
readonly plural: string;
|
|
19
|
+
/**
|
|
20
|
+
* snake_case 복수 — `table('...')` 문자열·DB 테이블명·tables.d.ts 키.
|
|
21
|
+
* 다단어는 **마지막 단어만** 복수화한다(결정 47 · Rails 관례): `PostTag`
|
|
22
|
+
* → `post_tags` · `AuditLog` → `audit_logs`. 조인 테이블 `posts_tags`
|
|
23
|
+
* (belongsToMany through · 결정 33)는 스키마를 그 이름으로 직접 정의한다.
|
|
24
|
+
*/
|
|
25
|
+
readonly table: string;
|
|
17
26
|
}
|
|
18
|
-
/** 사용자 입력(어느 형태든)에서
|
|
27
|
+
/** 사용자 입력(어느 형태든)에서 네 가지 변형을 파생한다. */
|
|
19
28
|
export declare function inflectModel(input: string): ModelNames;
|
package/dist/scaffold/inflect.js
CHANGED
|
@@ -20,6 +20,15 @@ export function toPascal(s) {
|
|
|
20
20
|
.map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
|
|
21
21
|
.join('');
|
|
22
22
|
}
|
|
23
|
+
/** 카멜/파스칼 → snake_case (`postTags` → `post_tags`, `AuditLogs` → `audit_logs`). */
|
|
24
|
+
export function toSnake(s) {
|
|
25
|
+
if (!s)
|
|
26
|
+
return s;
|
|
27
|
+
return s
|
|
28
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
29
|
+
.replace(/[-\s]+/g, '_')
|
|
30
|
+
.toLowerCase();
|
|
31
|
+
}
|
|
23
32
|
/** 단순 단수화 — 끝의 s 하나만 벗긴다 ('users' → 'user' · 'ss' 는 유지). */
|
|
24
33
|
export function singularize(s) {
|
|
25
34
|
if (s.endsWith('ies') && s.length > 3)
|
|
@@ -39,12 +48,15 @@ export function pluralize(s) {
|
|
|
39
48
|
}
|
|
40
49
|
return s + 's';
|
|
41
50
|
}
|
|
42
|
-
/** 사용자 입력(어느 형태든)에서
|
|
51
|
+
/** 사용자 입력(어느 형태든)에서 네 가지 변형을 파생한다. */
|
|
43
52
|
export function inflectModel(input) {
|
|
44
53
|
const trimmed = input.trim();
|
|
45
54
|
const singular = singularize(toCamel(toPascal(trimmed)));
|
|
46
55
|
const pascal = toPascal(singular);
|
|
47
56
|
const camel = toCamel(pascal);
|
|
48
57
|
const plural = pluralize(camel);
|
|
49
|
-
|
|
58
|
+
// 컬럼은 camelCase 지만 테이블명은 snake_case(결정 43 · agents/data.md §1.2).
|
|
59
|
+
// plural(camelCase) 을 snake 로 눕혀 마지막 단어 복수형을 유지한다.
|
|
60
|
+
const table = toSnake(plural);
|
|
61
|
+
return { pascal, camel, plural, table };
|
|
50
62
|
}
|
package/dist/scaffold/model.js
CHANGED
|
@@ -10,14 +10,16 @@
|
|
|
10
10
|
// 앱→도메인 import 만 허용되므로 이 위치가 유일한 정답이다.
|
|
11
11
|
/** 스키마 스캐폴드 — E-4 컬럼 타입 예시를 함께 담는다. */
|
|
12
12
|
export function schemaScaffold(names) {
|
|
13
|
-
const { pascal, plural } = names;
|
|
13
|
+
const { pascal, plural, table: tableName } = names;
|
|
14
|
+
// 파일명·export 심볼 = camelCase 복수(`postTags`), table() 문자열·GaonTables
|
|
15
|
+
// 키 = snake_case(`post_tags`). 컬럼은 camelCase(결정 43 · agents/data.md §1.2).
|
|
14
16
|
const lines = [
|
|
15
17
|
`// ${pascal} 스키마 — gaon g model (M9-B).`,
|
|
16
18
|
`// errata E-4 예시: unique · default · enum · nullable. 컬럼은 자유롭게 추가/삭제한다.`,
|
|
17
19
|
`// .gaon/tables.d.ts 가 자동 재생성돼 모델·컨트롤러·페이지 전 체인이 즉시 반영된다(§6.3).`,
|
|
18
20
|
`import { table, t, type RowOf } from 'gaonjs/data'`,
|
|
19
21
|
``,
|
|
20
|
-
`export const ${plural} = table('${
|
|
22
|
+
`export const ${plural} = table('${tableName}', {`,
|
|
21
23
|
` id: t.id(),`,
|
|
22
24
|
` title: t.string().max(200).unique(),`,
|
|
23
25
|
` status: t.enum(['draft', 'published']).default('draft'),`,
|
|
@@ -28,7 +30,7 @@ export function schemaScaffold(names) {
|
|
|
28
30
|
`// 모델·컨트롤러에서 즉시 참조 가능하다. 재생성 후에는 이 블록이 중복돼도 무해하다.`,
|
|
29
31
|
`declare module 'gaonjs/data' {`,
|
|
30
32
|
` interface GaonTables {`,
|
|
31
|
-
` ${
|
|
33
|
+
` ${tableName}: RowOf<typeof ${plural}>`,
|
|
32
34
|
` }`,
|
|
33
35
|
`}`,
|
|
34
36
|
``,
|
|
@@ -98,8 +98,12 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
98
98
|
- 다단어 테이블의 스키마 **파일명**은 camelCase (`posts_tags` →
|
|
99
99
|
`postsTags.ts`) — 파일 **안**의 `table('posts_tags', …)` 문자열은
|
|
100
100
|
스네이크 그대로.
|
|
101
|
+
- **식별자** (결정 43): 함수·변수·메서드 = camelCase · 타입·Vue 컴포넌트 =
|
|
102
|
+
PascalCase · 상수·환경 변수 = UPPER_SNAKE. **DB 네이밍**(테이블명 ·
|
|
103
|
+
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
104
|
+
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
101
105
|
|
|
102
|
-
### 2.2 `gaon doctor` 검사
|
|
106
|
+
### 2.2 `gaon doctor` 검사 12종
|
|
103
107
|
|
|
104
108
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
105
109
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -110,6 +114,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
110
114
|
7. `no-auto-import` — 자동 import 설정 (E-5 §2.4)
|
|
111
115
|
8. `schema-filename` — 스키마 파일명 camelCase 관례 (결정 38 · `--fix` 지원)
|
|
112
116
|
9. `agents-doc-index` — 이 문서 색인(§0) ↔ `agents/` 실 파일 불일치 (결정 40)
|
|
117
|
+
10. `column-casing` — 스키마 컬럼명 camelCase 관례 (결정 43·46 · 스네이크 컬럼 검출)
|
|
118
|
+
11. `model-filename` — 모델 파일명 PascalCase 관례 (결정 32·46 · `--fix` 지원)
|
|
119
|
+
12. `page-filename` — Vue 페이지 파일명 PascalCase 관례 (결정 32·46)
|
|
113
120
|
|
|
114
121
|
## 3. 로직 배치 One Way 판단표
|
|
115
122
|
|
|
@@ -150,7 +157,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
150
157
|
```bash
|
|
151
158
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
152
159
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
153
|
-
gaon doctor # 정적 검사
|
|
160
|
+
gaon doctor # 정적 검사 12종 (§2.2)
|
|
154
161
|
```
|
|
155
162
|
|
|
156
163
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -76,6 +76,30 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
76
76
|
- doctor 가 **대상·조인 테이블의 존재**와 **커넥션 경계**(§7)를
|
|
77
77
|
검사한다 (`unknown-relation-target` · `cross-connection-relation`).
|
|
78
78
|
|
|
79
|
+
### 1.2 DB 네이밍 (결정 43)
|
|
80
|
+
|
|
81
|
+
테이블명·컬럼명·파일명 사이의 표기와 변환은 아래가 전부다 — 추측하지 않는다.
|
|
82
|
+
|
|
83
|
+
| 대상 | 표기 | 예시 |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| 테이블명 (`table('...')` 문자열) | 소문자 · **복수** · 다단어 `snake_case` | `posts` · `posts_tags` |
|
|
86
|
+
| 컬럼명 (스키마 키 · Row 타입) | **camelCase** | `title` · `authorId` · `createdAt` |
|
|
87
|
+
| FK 컬럼 | 단수 테이블 + `Id` | `posts` → `postId` · `tags` → `tagId` |
|
|
88
|
+
| 타임스탬프 | `createdAt` · `updatedAt` (`...t.timestamps()`) | — |
|
|
89
|
+
| 스키마 파일명 | 테이블명을 **camelCase** 로 | `posts.ts` · `posts_tags` → `postsTags.ts` |
|
|
90
|
+
| export 심볼 | 파일명과 같은 camelCase | `export const postsTags = table('posts_tags', …)` |
|
|
91
|
+
| 모델 파일·심볼 | **PascalCase 단수** | `domain/models/Post.ts` · `export const Post` |
|
|
92
|
+
| `tables.d.ts` 키 · DB 실 테이블 | **테이블명 그대로**(snake_case) | `posts_tags: RowOf<typeof postsTags>` |
|
|
93
|
+
|
|
94
|
+
**변환 규칙은 하나** — 다단어 테이블 `posts_tags` 는:
|
|
95
|
+
|
|
96
|
+
- 스키마 **파일명**·**export 심볼** = camelCase `postsTags`,
|
|
97
|
+
- `table()` 문자열 · `tables.d.ts` 키 · DB 테이블 = snake_case `posts_tags`.
|
|
98
|
+
|
|
99
|
+
컬럼은 어느 경우에도 camelCase 다(스네이크 컬럼 리터럴 금지 · `createdAt`
|
|
100
|
+
이지 `created_at` 아님). 파일명 casing 은 doctor `schema-filename` 이
|
|
101
|
+
강제한다(단수/복수는 대상 밖 · 결정 38).
|
|
102
|
+
|
|
79
103
|
### 2. 컬럼 타입 전체 (실 구현 · `packages/data/src/schema.ts:394-445`)
|
|
80
104
|
|
|
81
105
|
| 빌더 | SQL 타입 | TS 타입 | 비고 |
|
|
@@ -516,7 +540,13 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
516
540
|
- **loop 안 관계 lazy 호출 = N+1** — doctor **n-plus-one** 검사가 잡는다.
|
|
517
541
|
목록은 `include()` 로.
|
|
518
542
|
- **스키마 파일명은 camelCase** — 테이블 `posts_tags` → 파일 `postsTags.ts`
|
|
519
|
-
(파일 안 `table('posts_tags', …)` 문자열은 스네이크 그대로).
|
|
543
|
+
(파일 안 `table('posts_tags', …)` 문자열은 스네이크 그대로). doctor
|
|
544
|
+
**schema-filename** 이 잡는다(`--fix` 지원 · 결정 38).
|
|
545
|
+
- **컬럼명은 camelCase** — 스네이크 컬럼(`created_at`·`author_id`)은 doctor
|
|
546
|
+
**column-casing** 이 잡는다(§1.2 · 결정 43·46). FK = 단수 테이블+`Id`
|
|
547
|
+
(`postId`) · 타임스탬프 `createdAt`/`updatedAt`. 테이블명 문자열은 snake.
|
|
548
|
+
- **모델 파일명은 PascalCase** — `domain/models/Post.ts`. 소문자·스네이크는
|
|
549
|
+
doctor **model-filename** 이 잡는다(`--fix` 지원 · 결정 32·46).
|
|
520
550
|
- **잡·이벤트 발행을 트랜잭션과 정합시키려면** `afterCommit()`(service 안)
|
|
521
551
|
또는 아웃박스(`agents/async.md`) — 커밋 전 발행은 롤백 시 유령 부수효과.
|
|
522
552
|
|
|
@@ -529,4 +559,7 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
529
559
|
| 결정 34 | 집계·조인 그룹(groupBy·having·distinct·withCount·join) (M2E) |
|
|
530
560
|
| 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) |
|
|
531
561
|
| 결정 39 | 마이그레이션 합성형(파일 replay → 스키마 diff · no-auto-drop) |
|
|
562
|
+
| 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
|
|
563
|
+
| 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
|
|
564
|
+
| 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
|
|
532
565
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -187,6 +187,9 @@ async function runSearch(q: string) {
|
|
|
187
187
|
- **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
|
|
188
188
|
`Inertia.post()` (`agents/web.md` §4).
|
|
189
189
|
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
|
|
190
|
+
- **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
|
|
191
|
+
Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
|
|
192
|
+
(결정 32·46). rename 후 컨트롤러 `this.render('...')` 키도 맞춘다.
|
|
190
193
|
- **템플릿 `:key="String(p.id)"` 방어 금지** — 정규화는 컨트롤러 한 곳
|
|
191
194
|
(결정 37).
|
|
192
195
|
- **`v-html` 은 XSS 탈출구** — 사용자 입력을 넣지 않는다
|
|
@@ -198,4 +201,5 @@ async function runSearch(q: string) {
|
|
|
198
201
|
|---|---|
|
|
199
202
|
| 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
|
|
200
203
|
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
|
|
204
|
+
| 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
|
|
201
205
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,18 +23,18 @@
|
|
|
23
23
|
"dist",
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|
|
28
|
-
},
|
|
29
26
|
"dependencies": {
|
|
30
|
-
"@gaonjs/async": "workspace:*",
|
|
31
|
-
"@gaonjs/config": "workspace:*",
|
|
32
|
-
"@gaonjs/core": "workspace:*",
|
|
33
|
-
"@gaonjs/data": "workspace:*",
|
|
34
|
-
"@gaonjs/web": "workspace:*",
|
|
35
|
-
"@gaonjs/mail": "workspace:*",
|
|
36
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
28
|
"typescript": "^5.9.0",
|
|
38
|
-
"vite": "^7.0.0"
|
|
29
|
+
"vite": "^7.0.0",
|
|
30
|
+
"@gaonjs/async": "0.3.0",
|
|
31
|
+
"@gaonjs/core": "0.1.4",
|
|
32
|
+
"@gaonjs/data": "0.8.0",
|
|
33
|
+
"@gaonjs/mail": "0.1.0",
|
|
34
|
+
"@gaonjs/web": "0.5.0",
|
|
35
|
+
"@gaonjs/config": "0.2.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|
|
39
39
|
}
|
|
40
|
-
}
|
|
40
|
+
}
|