@gaonjs/cli 0.65.0 → 0.65.3

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.
@@ -15,7 +15,7 @@ export declare const FIXERS: Partial<Record<DoctorRule, Fixer>>;
15
15
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
16
16
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
17
17
  *
18
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 31종을 **빠짐없이** 담는다 —
18
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 전종을 **빠짐없이** 담는다 —
19
19
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
20
20
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
21
21
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -31,7 +31,7 @@ export const FIXERS = {
31
31
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
32
32
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
33
33
  *
34
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 31종을 **빠짐없이** 담는다 —
34
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 전종을 **빠짐없이** 담는다 —
35
35
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
36
36
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
37
37
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -103,6 +103,11 @@ export const FIXER_CAPABILITIES = [
103
103
  hasFixer: false,
104
104
  note: '수동 · AGENTS 색인(§0)과 agents/ 실 파일을 일치시킵니다(결정 40).',
105
105
  },
106
+ {
107
+ rule: 'redundant-index',
108
+ hasFixer: false,
109
+ note: "수동 · '.index()' 제거 자체는 안전하지만, 원래 의도가 이 컬럼을 선두로 하는 복합 인덱스나 부분 인덱스(.index({ where })) 였을 수 있어 테이블 레벨로 옮길지 지울지는 사람이 판단합니다(결정 460).",
110
+ },
106
111
  {
107
112
  rule: 'column-casing',
108
113
  hasFixer: false,
@@ -0,0 +1,7 @@
1
+ import type { RuleReport } from './types.js';
2
+ /**
3
+ * unique·PK 컬럼에 붙은 `.index()` 를 검사한다. domain/schema 가 없으면 통과.
4
+ * 스키마 로드 실패는 schema-relations 가 이미 안내하므로 여기서는 무소음으로
5
+ * 넘어간다(같은 원인에 대한 경고 중복 방지).
6
+ */
7
+ export declare function checkRedundantIndex(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,67 @@
1
+ // @gaonjs/cli · doctor · unique·PK 컬럼의 무시되는 `.index()` (결정 460)
2
+ //
3
+ // `t.string().unique().index()` 처럼 unique(또는 PK) 컬럼에 명시적 `.index()` 를
4
+ // 붙이면 DDL 생성기(ddl.ts tableIndexSpecs)가 그 인덱스를 **건너뛴다** — UNIQUE
5
+ // 제약·PK 가 이미 인덱스라 중복이기 때문이다. DDL 결과는 옳다. 문제는 그 skip 이
6
+ // **조용했다**는 것: 개발자는 인덱스를 요청했는데 아무것도 생성되지 않고 아무
7
+ // 신호도 없다(부분 인덱스 `.index({ where })`·인덱스 메서드 `.index({ using })`
8
+ // 도 함께 증발한다 — 이쪽은 "중복이라 괜찮다" 도 아니다).
9
+ //
10
+ // 왜 빌더·타입에서 막지 않는가(결정 460 기각안): 수식어 체이닝은 "어떤 조합·
11
+ // 순서든 컴파일된다"가 의도된 성질이고 type-tests(columnModifiers.test-d.ts)가
12
+ // `.unique().index()` 를 포함한 전 수식어 체인을 그렇게 고정한다. 빌더 시그니처를
13
+ // 좁히면 그 성질과 테스트가 깨지므로, 신호는 정적 검사로만 준다.
14
+ //
15
+ // 경고(warning)다 — DDL 은 이미 옳고 기존 스키마는 그대로 동작한다. 자동 수정은
16
+ // 두지 않는다: `.index()` 제거는 안전하지만, 사용자가 원한 것이 **복합·부분
17
+ // 인덱스**였을 수 있어(테이블 레벨로 옮겨야 한다) 무엇을 의도했는지는 사람이
18
+ // 판단한다(fixers/index.ts capability note).
19
+ //
20
+ // 판정은 AST 가 아니라 **실제 ColumnMeta**(schema-relations 와 같은 로드 경로)로
21
+ // 한다 — ddl.ts 가 보는 값과 같은 값을 봐야 "무시된다" 는 판정이 DDL 과 절대
22
+ // 어긋나지 않는다(헬퍼로 조립한 컬럼·spread 도 그대로 잡힌다).
23
+ import { existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { checkRedundantIndexes } from '@gaonjs/data';
26
+ import { loadAllTables } from './schema-relations.js';
27
+ /** 진단 message 앞머리(`<table>.<column> …`)에서 소유 테이블을 뽑는다(파일 부착용). */
28
+ function ownerOf(message) {
29
+ const m = message.match(/^([A-Za-z0-9_]+)\./);
30
+ return m ? m[1] : '';
31
+ }
32
+ /** 진단 message 앞머리(`<table>.<column> …`)에서 컬럼명을 뽑는다(--json detail). */
33
+ function columnOf(message) {
34
+ const m = message.match(/^[A-Za-z0-9_]+\.([A-Za-z0-9_]+)/);
35
+ return m ? m[1] : '';
36
+ }
37
+ /**
38
+ * unique·PK 컬럼에 붙은 `.index()` 를 검사한다. domain/schema 가 없으면 통과.
39
+ * 스키마 로드 실패는 schema-relations 가 이미 안내하므로 여기서는 무소음으로
40
+ * 넘어간다(같은 원인에 대한 경고 중복 방지).
41
+ */
42
+ export async function checkRedundantIndex(cwd) {
43
+ const schemaDir = join(cwd, 'domain', 'schema');
44
+ if (!existsSync(schemaDir))
45
+ return { rule: 'redundant-index', issues: [] };
46
+ let tables;
47
+ try {
48
+ tables = await loadAllTables(cwd, schemaDir);
49
+ }
50
+ catch {
51
+ return { rule: 'redundant-index', issues: [] };
52
+ }
53
+ // data 패키지 구현을 그대로 호출한다(재구현 금지 · 결정 134 관례).
54
+ const diags = checkRedundantIndexes(tables.tables);
55
+ const issues = diags.map((d) => {
56
+ const owner = ownerOf(d.message);
57
+ const file = tables.fileOf.get(owner);
58
+ return {
59
+ rule: 'redundant-index',
60
+ level: d.level === 'error' ? 'error' : 'warning',
61
+ message: d.message,
62
+ ...(file ? { file } : {}),
63
+ detail: { code: d.code, table: owner, column: columnOf(d.message) },
64
+ };
65
+ });
66
+ return { rule: 'redundant-index', issues };
67
+ }
@@ -1,4 +1,17 @@
1
+ import { type TableDef } from '@gaonjs/data';
1
2
  import type { RuleReport } from './types.js';
3
+ /**
4
+ * domain/schema/ 의 모든 TableDef 를 커넥션 구분 없이 로드하고, 각 테이블이
5
+ * 어느 소스 파일에서 왔는지 함께 기록한다(진단에 파일 위치를 붙이기 위함).
6
+ *
7
+ * redundant-index(결정 460)도 같은 로드 경로를 쓴다 — 스키마 로드는 한 곳에만
8
+ * 둔다(scanSchemaDir 호출 관례가 검사마다 갈라지면 tsResolve 등록·경로 정규화가
9
+ * 서로 어긋난다).
10
+ */
11
+ export declare function loadAllTables(cwd: string, schemaDir: string): Promise<{
12
+ tables: TableDef[];
13
+ fileOf: Map<string, string>;
14
+ }>;
2
15
  /**
3
16
  * 프로젝트 전체의 §4.5 관계 제약을 검사한다. domain/schema 가 없으면 통과.
4
17
  * 스키마 로드 실패(문법 오류 등)는 이 검사만 warning 으로 강등하고 안내한다 —
@@ -31,8 +31,12 @@ function isTableDef(v) {
31
31
  /**
32
32
  * domain/schema/ 의 모든 TableDef 를 커넥션 구분 없이 로드하고, 각 테이블이
33
33
  * 어느 소스 파일에서 왔는지 함께 기록한다(진단에 파일 위치를 붙이기 위함).
34
+ *
35
+ * redundant-index(결정 460)도 같은 로드 경로를 쓴다 — 스키마 로드는 한 곳에만
36
+ * 둔다(scanSchemaDir 호출 관례가 검사마다 갈라지면 tsResolve 등록·경로 정규화가
37
+ * 서로 어긋난다).
34
38
  */
35
- async function loadAllTables(cwd, schemaDir) {
39
+ export async function loadAllTables(cwd, schemaDir) {
36
40
  registerTsResolve();
37
41
  // outDir 은 import 지정자 계산에만 쓰이고 파일을 쓰지 않는다(resolve.ts 관례).
38
42
  const outDir = join(cwd, '.gaon');
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | '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' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch' | 'i18n-layout' | 'i18n-app-scope' | 'i18n-server-scope';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | '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' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch' | 'i18n-layout' | 'i18n-app-scope' | 'i18n-server-scope' | 'redundant-index';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -7,6 +7,7 @@ export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one
7
7
  export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
8
8
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
9
9
  export { checkSchemaRelations } from './doctor/schema-relations.js';
10
+ export { checkRedundantIndex } from './doctor/redundant-index.js';
10
11
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
11
12
  export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
12
13
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
@@ -36,7 +37,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
36
37
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
37
38
  */
38
39
  /**
39
- * doctor 정적 검사 36종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
40
+ * doctor 정적 검사 37종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
40
41
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
41
42
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
42
43
  */
package/dist/doctor.js CHANGED
@@ -39,6 +39,7 @@
39
39
  * 34) i18n-app-scope (결정 454·459 · 그 앱 카탈로그에 없는 클라 t() 키 = 런타임 키 노출 ·
40
40
  * shared/ 가 쓰는 키는 전 앱에 있어야 한다)
41
41
  * 35) i18n-server-scope (결정 459 · 소유자 경계를 넘는 서버 t() 키 = 워커·크론에서 조용히 빔)
42
+ * 36) redundant-index (결정 460 · unique·PK 컬럼의 `.index()` = DDL 이 조용히 건너뜀 경고)
42
43
  *
43
44
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
44
45
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -57,6 +58,7 @@ import { checkNPlusOne } from './doctor/n-plus-one.js';
57
58
  import { checkDependencyDirection } from './doctor/dependency-direction.js';
58
59
  import { checkConnections } from './doctor/connections.js';
59
60
  import { checkSchemaRelations } from './doctor/schema-relations.js';
61
+ import { checkRedundantIndex } from './doctor/redundant-index.js';
60
62
  import { checkMigrationDiff } from './doctor/migration-diff.js';
61
63
  import { checkSharedPurity } from './doctor/shared-purity.js';
62
64
  import { checkNoAutoImport } from './doctor/no-auto-import.js';
@@ -97,6 +99,7 @@ export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one
97
99
  export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
98
100
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
99
101
  export { checkSchemaRelations } from './doctor/schema-relations.js';
102
+ export { checkRedundantIndex } from './doctor/redundant-index.js';
100
103
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
101
104
  export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
102
105
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
@@ -126,7 +129,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
126
129
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
127
130
  */
128
131
  /**
129
- * doctor 정적 검사 36종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
132
+ * doctor 정적 검사 37종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
130
133
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
131
134
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
132
135
  */
@@ -167,6 +170,7 @@ export const ALL_RULES = [
167
170
  'i18n-layout',
168
171
  'i18n-app-scope',
169
172
  'i18n-server-scope',
173
+ 'redundant-index',
170
174
  ];
171
175
  /**
172
176
  * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
@@ -211,6 +215,7 @@ export const RULE_SUMMARIES = {
211
215
  'channel-instance-authorize': '인스턴스 채널 authorize',
212
216
  'dotenv-node-env': '.env NODE_ENV',
213
217
  'page-fetch': '세션 앱 raw fetch',
218
+ 'redundant-index': 'unique·PK 컬럼의 무시되는 .index()',
214
219
  };
215
220
  const CHECKERS = {
216
221
  'response-mixing': checkResponseMixing,
@@ -244,6 +249,7 @@ const CHECKERS = {
244
249
  'i18n-layout': checkI18nLayout,
245
250
  'i18n-app-scope': checkI18nAppScope,
246
251
  'i18n-server-scope': checkI18nServerScope,
252
+ 'redundant-index': checkRedundantIndex,
247
253
  'render-return': checkRenderReturn,
248
254
  'channel-collision': checkChannelCollision,
249
255
  'channel-instance-authorize': checkChannelInstanceAuthorize,
@@ -113,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
113
113
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
114
114
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
115
115
 
116
- ### 2.2 `gaon doctor` 검사 36
116
+ ### 2.2 `gaon doctor` 검사 37
117
117
 
118
118
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
119
119
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -151,6 +151,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
151
151
  34. `i18n-layout` — **옛 카탈로그 배치**가 남아 있음 = **경고**(폐지된 루트 `locales/`·앱 평면 `<로케일>.json`) · **오류**(`domain/locales/<로케일>/frontend.json`·제거된 `i18n.dir` 설정). 결정 459 의 정본 배치는 소유자 2개다 — `domain/locales/<로케일>/backend.json`(도메인 공통 서버 문구) · `apps/<앱>/locales/<로케일>/{frontend,backend}.json`(그 앱 화면·서버 문구). 옛 배치를 그냥 두면 **아무도 읽지 않는 폴더**가 되어 전 화면·전 메일이 키 문자열로 degrade 하므로 조용히 두지 않는다. `gaon doctor --fix` 가 이관한다(루트 backend·레거시 단일 파일 → `domain/locales`, 앱 평면 파일 → 그 앱 `frontend.json`, 루트 공용 frontend → **전 앱 복제 후 원본 제거**). 전 앱 복제는 되돌리기 쉬운 방향이라 자동화하고, 잉여 키 정리·도메인 frontend 재배치는 사람이 판단한다 (결정 454·459 · `agents/i18n.md`)
152
152
  35. `i18n-app-scope` — 앱 코드가 **그 앱 카탈로그에 없는** 클라 `t()` 키를 참조, 또는 `shared/` 컴포넌트가 쓰는 키가 **일부 앱에만** 존재 = **오류**. 클라 키 유니온은 프로젝트 전체 frontend 합집합이라(두 앱이 같은 `GaonMessages.keys` 를 다른 유니온으로 augment 하면 TS2717 이고 `gaon check` 는 단일 tsc 프로그램이다 — 앱별 유니온이 구조적으로 불가) 타입만으로는 앱 경계를 못 지킨다. 앱 번들에는 그 앱 카탈로그만 실리므로 다른 앱 전용 키는 런타임에 번역 대신 **키 문자열이 그대로** 보인다. `shared/` 는 어느 앱 번들에도 실릴 수 있어 그 키는 **모든 앱**에 있어야 한다(공용 카탈로그는 폐지됐다 — 결정 459 O1 · 복제 + 이 검사로 강제). 문구는 `apps/<앱>/locales/<로케일>/frontend.json` 에 두고 `gaon gen`. 서버 `t()`(`gaonjs/i18n`) 호출은 `i18n-server-scope` 담당이다 (결정 454·459)
153
153
  36. `i18n-server-scope` — 서버 `t()`(`gaonjs/i18n`)가 **소유자 경계**를 넘는 키를 참조 = **오류**. `domain/**` 은 `domain/locales` backend 만, `apps/<앱>/**` 은 그 앱 backend ∪ frontend ∪ domain backend 만 볼 수 있다. 이 유형은 두 층이 모두 놓친다 — 서버 키 유니온은 domain ∪ 전 앱이라 **컴파일을 통과**하고(결정 459 O5), 요청 컨텍스트에서는 앱 네임스페이스가 상속돼 **우연히 해석된다**. 같은 코드가 워커(`gaon work`)·크론에서 불리면 앱 스코프가 없어 키가 비므로 "개발 중엔 되는데 잡에서만 키 문자열이 뜬다" 로 샌다. 결정 455 의 2단 계약(빌드=차단 / 런타임=키 렌더+경고)에서 **빌드=차단의 절반이 무너지는** 지점이라 정적으로 못박는다. 수리: 문구를 `domain/locales/<로케일>/backend.json` 으로 올리거나 그 호출을 도메인 밖으로 옮긴다 (결정 459 · `agents/i18n.md` §3)
154
+ 37. `redundant-index` — **unique·PK 컬럼에 붙은 `.index()`** = **경고**. UNIQUE 제약과 PK 는 그 자체가 인덱스라 컬럼 레벨 `.index()` 는 중복이고, DDL 생성기가 정확히 그래서 **건너뛴다**(스키마·DDL 결과는 옳다). 문제는 그 skip 이 조용해 개발자가 no-op 인 줄 모르는 것 — 특히 `.index({ where: ... })`(부분 인덱스)·`.index({ using: 'brin' })`(인덱스 메서드)까지 함께 증발하는데 아무 신호가 없다. 수리: 그 `.index()` 를 지우거나(지워도 결과 동일), 원래 의도가 **그 컬럼을 선두로 하는 복합·부분 인덱스**였다면 테이블 레벨 `table('t', {...}, { index: [['email','teamId']] })` 로 옮긴다. 빌더·타입은 막지 않는다 — 수식어는 어떤 조합·순서든 컴파일되는 것이 의도된 성질이라(`.unique().index()` 포함) 신호는 이 정적 검사로만 준다. `--fix` 없음(지울지 옮길지는 의도 판단 · 결정 460 · `agents/data.md` §3)
154
155
 
155
156
  ## 3. 로직 배치 One Way 판단표
156
157
 
@@ -211,7 +212,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
211
212
  ```bash
212
213
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
213
214
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
214
- gaon doctor # 정적 검사 36종 (§2.2)
215
+ gaon doctor # 정적 검사 37종 (§2.2)
215
216
  ```
216
217
 
217
218
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -164,6 +164,11 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
164
164
  - `.index({ where: "status = 'active'" })` — partial(부분) 인덱스.
165
165
  - method 집합 = `btree | gin | brin | gist`. **jsonb→gin 자동은 컬럼 `.index()` 한 곳뿐** —
166
166
  brin/gist 는 명시(과유도가 perpetual-diff 를 늘림). **`t.jsonb()` 도 `.index()` 를 가진다**(결정 273 신설).
167
+ - **unique·PK 컬럼에는 `.index()` 를 붙이지 않는다**(결정 460). UNIQUE 제약·PK 가 이미
168
+ 인덱스라 DDL 이 그 `.index()` 를 **생성하지 않는다** — 옵션(`where`·`using`)까지 함께
169
+ 증발한다. 체이닝은 컴파일되므로(수식어는 어떤 조합·순서든 통과) `gaon doctor` 의
170
+ `redundant-index` 가 경고로 알린다. 그 컬럼을 **선두로 하는 복합·부분 인덱스**가
171
+ 필요했다면 테이블 레벨 `{ index: [[...]] }` 로 옮긴다.
167
172
  - `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4 · `enum`·`uuid`·`belongsTo`·`bytea`
168
173
  제외 — §3.1).
169
174
 
@@ -1340,3 +1345,4 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
1340
1345
  | 결정 336 | 중첩 hidden dot-path 실지원(290 대체) — 중첩 객체·type:{} 서브도큐먼트·서브도큐먼트 배열 · 직렬화 경계 상속 스레딩 · 지원 밖 위치는 fail-loud(§7.1) |
1341
1346
  | E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
1342
1347
  | **결정 456** | **수식어 × 빌더 매트릭스(§3.1) + `t.belongsTo(...).unique()` 신설** — "모든 타입에 적용" 이라 적고 조합표가 없어 `t.belongsTo('x').unique()` 가 migrate 를 죽였다(auction 실측). 1:1 FK 는 정당한 도메인 요구라 `unique` 는 **열고**, `default`·`check` 는 참조 무결성과 어긋나 **닫는다**(표에 이유까지 명기 · `type-tests/columnModifiers.test-d.ts` 가 매트릭스를 타입으로 고정) |
1348
+ | 결정 460 | unique·PK 컬럼의 `.index()` 는 DDL 이 건너뛴다(중복) — 조용한 no-op 을 `gaon doctor` `redundant-index` **경고**로 표면화(§3 `.index()`) · 빌더·타입은 무변경(체이닝 자유는 의도된 성질) |
@@ -51,9 +51,11 @@ const props = pageProps<'web:posts#index'>()
51
51
  // 앱이 app.config sharedProps 로 등록한 키(locale·theme 등)도 같은 자리에서 읽힌다(결정 150).
52
52
  ```
53
53
  **i18n 문구는 여기가 아니라 클라 `t()` 로 온다**(결정 454) — `import { t } from 'gaonjs/vue'`
54
- 로 `.vue` 에서 직접 부른다. 카탈로그는 `locales/<로케일>/frontend.json`(앱 공용)과
55
- `apps/<앱>/locales/<로케일>.json`( 전용)이고, 서버 전용 문구(`backend.json`)를 클라에서
56
- 참조하면 컴파일 에러다(`agents/i18n.md`). 컨트롤러 render props·`sharedProps` 로 번역을
54
+ 로 `.vue` 에서 직접 부른다. 카탈로그는 **`apps/<앱>/locales/<로케일>/frontend.json`**
55
+ (앱마다 필수 · 결정 459) 하나이고 **루트 공용 `locales/` 는 폐지됐다** — 서버 전용
56
+ 문구(`apps/<앱>/locales/<로케일>/backend.json` · `domain/locales/<로케일>/backend.json`)
57
+ 클라에서 참조하면 컴파일 에러다(`agents/i18n.md`). 여러 앱이 함께 쓰는 문구는 앱마다
58
+ 같은 키를 복제한다(doctor **i18n-app-scope**). 컨트롤러 render props·`sharedProps` 로 번역을
57
59
  흘려보내던 옛 경로(결정 213)는 탈출구로만 남는다 — `sharedProps` 는 번역과 무관한 앱 공유
58
60
  값(테마·플래그)에 쓴다.
59
61
  `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
@@ -340,83 +342,204 @@ import PageShell from '@shared/components/ui/PageShell.vue'
340
342
  | 원자 (18) | Button · Input · Label · Badge · Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter · Alert · AlertTitle · AlertDescription · Form · FormField · FormMessage · Dialog · Sheet |
341
343
  | 블록 (4 · 결정 106) | PageShell · PageHeader · EmptyState · Pagination |
342
344
 
343
- **슬롯·props 요약 (첫 시도용 · 소스 안 읽어도 되게 · O-2):** 카탈로그는 이름만이라 슬롯/prop 을
344
- 소스에서 찾아야 했다자주 쓰는 표면을 여기 못박는다(전체·정확한 타입은 컴포넌트 소스가 정본).
345
- **주의: named slot 이름이 비대칭이다** `PageHeader` 는 `#actions`(**복수**), `EmptyState`
346
- `#action`(**단수**). 기본 슬롯을 잘못 쓰면 조용히 그려진다.
345
+ **컴포넌트 사용법 — 킷 22종 전수 (첫 시도용 · 소스 안 읽어도 되게 · O-2).** 카탈로그가
346
+ 이름만이면 슬롯/prop 을 소스에서 찾아야 한다표면 전부를 여기 못박는다. 아래 값은
347
+ `gaon g ui-kit` 심는 **템플릿 소스의 `defineProps`·`defineEmits`·`<slot>` 에서 확인한
348
+ 것**이다(그래도 정본은 프로젝트에 복사된 여러분의 파일이다 고쳤다면 고친 쪽이 맞다).
349
+ **주의: named slot 이름이 비대칭이다** — `PageHeader` 는 `#actions`(**복수**), `EmptyState`
350
+ 는 `#action`(**단수**). 슬롯 이름을 틀리면 오류 없이 **조용히 안 그려진다**.
347
351
 
348
- | 컴포넌트 | props | slots | emits |
352
+ *블록 (4 · 결정 106) 화면 골격.*
353
+
354
+ | 컴포넌트 | props (기본값) | slots | emits |
349
355
  |---|---|---|---|
350
- | **PageShell** | `size?: 'default'\|'narrow'\|'wide'\|'full'` | 기본 | — |
351
- | **PageHeader** | `title?` · `description?` | `#title` · `#description` · **`#actions`**(복수) | — |
352
- | **EmptyState** | `title?` · **`description?`(prop)** | `#icon` · `#title` · `#description` · **`#action`**(단수) | — |
353
- | **Pagination** | `page`(필수) · `pageCount`(필수) · `siblings?=1` | — | `update:page` (= `v-model:page`) |
354
- | Button | `variant?='default'` · `size?='default'` · `type?='button'` · `href?` · `external?` · `target?` | 기본 | 네이티브(예 `@click`) |
355
- | FormField | `label?` · `error?` | 기본(컨트롤) | |
356
- | FormMessage | `message?` | — | — |
357
- | Alert | `variant?: 'default'\|'destructive'` | 기본 | |
358
- | Badge | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'` | 기본 | — |
359
- | Card / CardHeader / CardTitle / CardDescription / CardContent / CardFooter | | 기본(조합) | |
360
-
361
- - **블록은 성격 중립(결정 106)** 관리자/프론트를 나누지 않고 앱에서 쓴다.
362
- `PageShell`(최대폭·여백·세로 리듬) · `PageHeader`(제목+설명+액션) · `EmptyState`
363
- (빈 목록) · `Pagination`(페이지 이동 · `v-model:page`). 이외 블록(DataTable·StatCard·
364
- Tabs 등)은 아직 만들지 않는다(예약 · 실물 도그푸딩 후).
365
- - **반응형은 책임(결정 107)** — 폭·여백·열 같은 레이아웃 반응형은 `PageShell`
366
- 블록이 소유한다. **페이지 코드에 레이아웃 브레이크포인트(`sm:flex-row`·
367
- `md:grid-cols-2` 등)를 직접 쓰지 않는다** 킷에 표현이 있으면 킷을 쓴다.
368
- (탈출구: 킷에 없는 표현이면 Tailwind 유틸을 직접 써도 된다 doctor
369
- **page-layout-breakpoint** 강제가 아닌 **안내 경고**다.)
370
- - **폼은 UI Form + gaonjs `useForm`(결정 64)** `Form` 얇은 `<form>` 래퍼로
371
- `@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
372
- 않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
373
- 오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을 표시한다 —
374
- 서버 스키마 검증 실패는 `form.errors.<field>` **자동 반영**된다(결정 109 ·
375
- 컨트롤러가 손으로 다시 렌더하지 않는다 · `agents/web.md` §4.1).
376
- - **`class` 는 폴스루로 병합**단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
377
- 넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
378
- 자동 해소를 하지 않는다 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
379
- - **버튼 모양 링크 = `<Button href>`(결정 113) — `Link` 로 `Button` 을 감싸지 않는다.**
380
- `<Link href="/x"><Button>…</Button></Link>``<a><button>` 중첩(HTML 비준수·접근성
381
- 결함)이다. `Button` `href` 주면 내부에서 SPA 이동 링크(`Link`=`<a>`)로 렌더한다:
382
- `<Button href="/posts/new">새 글</Button>`. 외부 URL `<Button href="https://…" external
383
- target="_blank">`. 순수 버튼은 `href` 없이 `<Button @click="…">`. doctor **link-button-nesting**
384
- Link>Button 중첩을 경고한다.
385
- - **디자인 토큰은 `style.css` 곳(결정 74)** — 컴포넌트는 `bg-primary`·
386
- `text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
387
- `:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
388
- - **shared 킷의 허용/금지 API(결정 25·105)** — 킷은 `shared/` 라 라우트를 몰라야
389
- 한다: 허용 = 라우트 키와 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 =
390
- 라우트 지식(`api()`·`pageProps`). 데이터는 props 받는다(예 `Pagination`
391
- `v-model:page` 현재 페이지만 올려보내고 실제 이동은 페이지가 정한다).
392
- - **`Pagination` 블록은 `paginate()` 결과에 바로 맞는다(결정 119·106)** — 컨트롤러가
393
- `chain.paginate(page, perPage)` 만든 `{ rows, total, page, pageCount, perPage }`
394
- 통째로 넘기면, 블록의 `:page`·`:pageCount` 필드명 그대로 붙는다(매핑 보일러플레이트 0).
395
- ```vue
396
- <script setup lang="ts">
397
- import Pagination from '@shared/components/ui/Pagination.vue'
398
- import { pageProps, router } from 'gaonjs/vue'
399
- const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
400
- function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
401
- </script>
402
- <template>
403
- <article v-for="post in props.page.rows" :key="post.id">…</article>
404
- <Pagination :page="props.page.page" :page-count="props.page.pageCount" @update:page="goto" />
405
- </template>
406
- ```
407
- - **멀티앱은 앱마다 Tailwind 배선이 따로다(결정 76)** — 킷은 shared 한 벌이지만,
408
- 앱이 Tailwind 유틸을 받으려면 앱에 `style.css` 배선이 있어야 한다.
409
- `gaon g app admin` 배선을 동봉하고, `gaon g ui-kit --app admin` 은 배선이
410
- 없으면 멱등 보정한다(`--app` 이제 위치가 아니라 배선만 정한다).
411
- `tailwind.config.ts`·`postcss.config.js` 프로젝트 루트 공유이고 `content`
412
- `apps/**` `shared/**` 함께 훑는다. 앱이 킷을 import 하는데 배선이 없으면
413
- doctor **ui-kit-wiring** 경고한다.
414
-
415
- **기존 프로젝트 마이그레이션(결정 105 이전 이후):** 앱별 사본(`apps/<앱>/components/ui`
416
- ·`apps/<앱>/lib/utils.ts`)이 있으면 `gaon g ui-kit` 다시 실행해 `shared/` 킷을
417
- 만든 뒤, 앱 사본을 지우고 import 를 `@shared/components/ui/…` 로 바꾼다.
418
- `tailwind.config.ts` `content` `./shared/**/*.{vue,ts}` 있는지도 확인한다
419
- (스캐폴드 기본값엔 이미 포함).
356
+ | **PageShell** | `size?: 'default'\|'narrow'\|'wide'\|'full'` (`'default'`) | 기본 | — |
357
+ | **PageHeader** | `title?: string` · `description?: string` | 기본 없음 · `#title` · `#description` · **`#actions`**(복수) | — |
358
+ | **EmptyState** | `title?: string` · `description?: string` | 기본 없음 · `#icon` · `#title` · `#description` · **`#action`**(단수) | — |
359
+ | **Pagination** | `page: number`(필수) · `pageCount: number`(필수) · `siblings?: number` (`1`) | — | `update:page` (= `v-model:page`) |
360
+
361
+ *원자 (18) — 표면·컨트롤.*
362
+
363
+ | 컴포넌트 | props (기본값) | slots | emits |
364
+ |---|---|---|---|
365
+ | **Button** | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'\|'ghost'\|'link'` (`'default'`) · `size?: 'default'\|'sm'\|'lg'\|'icon'` (`'default'`) · `type?: 'button'\|'submit'\|'reset'` (`'button'`) · `href?: string` · `external?: boolean` · `target?: string` | 기본 | 네이티브(예 `@click`) |
366
+ | **Input** | `modelValue?: string \| number` | — | `update:modelValue` (= `v-model`) |
367
+ | **Label** || 기본 | |
368
+ | **Badge** | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'` (`'default'`) | 기본 | — |
369
+ | **Alert** | `variant?: 'default'\|'destructive'` (`'default'`) | 기본 | — |
370
+ | **AlertTitle** / **AlertDescription** | | 기본 | — |
371
+ | **Card** / **CardHeader** / **CardTitle** / **CardDescription** / **CardContent** / **CardFooter** | | 기본(조합) | |
372
+ | **Form** | | 기본 | `submit` (`@submit` 를 `useForm` 의 post/put/delete 로 넘긴다) |
373
+ | **FormField** | `label?: string` · `error?: string` | 기본(컨트롤) | — |
374
+ | **FormMessage** | `message?: string` | ||
375
+ | **Dialog** | `open: boolean`(필수) | 기본 | `update:open` (= `v-model:open`) |
376
+ | **Sheet** | `open: boolean`(필수) · `side?: 'left'\|'right'` (`'right'`) | 기본 | `update:open` (= `v-model:open`) |
377
+
378
+ **쓸 자주 틀리는 것:**
379
+
380
+ - **`PageHeader`·`EmptyState` 기본 슬롯이 없다** 제목/설명은 prop 이거나 named slot
381
+ 이다. `<PageHeader>제목</PageHeader>` 아무것도 그린다(오류도 난다).
382
+ - **`Dialog`·`Sheet` 는 `v-model:open` 이다**`:open` 주면 열리기만 하고 안 닫힌다
383
+ (닫기 요청이 `update:open` 으로 나가는데 받는 쪽이 없다).
384
+ - **`Pagination` `v-model:page` 이거나 `@update:page`** 실제 이동(`router.get`)은
385
+ **페이지가** 한다. 킷은 라우트를 모른다(결정 25·105).
386
+ - **`Input` 의 `update:modelValue` `string`** 이다. 숫자를 받아야 하면 페이지에서
387
+ 변환한다(`Number(...)`) 킷이 타입을 추측하지 않는다.
388
+ - **`Button` variant 6종뿐이다** `dangerSoft`·`plain` 같은 이름은 이 킷에 없다.
389
+ 필요하면 여러분의 `Button.vue` 직접 더한다(복사-소유).
390
+ - **`class` 는 그냥 넘기면 루트로 폴스루된다** — `<Button class="w-full">`. 별도
391
+ `class` prop 없다.
392
+
393
+ > **왜 여기 코드 예시가 없나:** 문서 예시 컴파일 게이트(결정 458)의 픽스처
394
+ > 워크스페이스에는 킷이 `Button`·`Card`·`PageShell` 있고 나머지(`PageHeader`·
395
+ > `EmptyState`·`Pagination`·`Form`·`FormField`·`Input`·`Dialog`·`Badge`) 없어,
396
+ > 이들을 쓰는 예시는 검증할 없다. 검증 못 하는 예시는 싣지 않는다 — 위 표가
397
+ > 표면의 정본이고, 조합 예시는 `gaon new` 스캐폴드의 실제 페이지가 정본이다.
398
+
399
+ **킷을 넓힐 관리 화면 블록 관례.** 카탈로그(원자 18 + 블록 4)는 `gaon new` 가
400
+ 심는 **최소 벌**이다. 관리 화면을 만들면 목록·표·트리 같은 블록이 곧 필요해지는데
401
+ 이것들은 **프레임웍이 주지 않는다** — 여러분 프로젝트의 `shared/components/ui/` 에
402
+ 직접 만들어 소유한다(위 "예약" 항목). 그때의 관례는 아래와 같다. 이름은 예시일 뿐
403
+ 프레임웍 API 아니다 — 규칙만 가져가고 이름은 프로젝트가 정한다.
404
+
405
+ - **목록 화면은 순서를 고정한다** — 헤더 → 필터 바 → 목록 카드(편집 + 본문)
406
+ 페이지네이션. 화면마다 순서가 다르면 같은 콘솔 안에서 손이 헤맨다. 블록 하나가
407
+ 이 순서를 소유하고 각 자리를 named slot 으로 연다. **빈 슬롯은 그 줄째 사라지게**
408
+ 한다 — 자리를 남기려고 공백을 넣지 않는다.
409
+ - **목록의 표는 CSS 그리드로 짠다** — `display:grid` + `grid-template-columns`.
410
+ 컬럼 폭·정렬·좁은 화면 숨김이 **컬럼 정의 한 곳**에 모여, 열을 하나 넣고 뺄 때
411
+ `colgroup`·`th`·`td` 세 군데를 고칠 일이 없다. 체크 열·액션 열을 앞에 붙이는
412
+ 계산도 문자열 하나로 끝난다. **대신 `role` 을 직접 붙인다** — `<table>` 이 공짜로
413
+ 주던 행·열 관계가 그리드엔 없어서, `role="grid"/"row"/"columnheader"/"gridcell"`
414
+ `aria-sort` 없으면 스크린리더에는 표가 아니라 글자 더미로 읽힌다. `<table>`
415
+ 본문 안에 끼우는 짧은 표에만 남긴다.
416
+ - **정렬 헤더는 목록 전체가 클라이언트에 있을 때만 연다.** 서버 페이지네이션
417
+ (`paginate()`) 목록에서 헤더 정렬을 열면 **현재 쪽만** 정렬돼 "가격 높은 순" 이
418
+ 전체가 아니라 안에서만 맞는 거짓말이 된다. 서버 정렬을 붙이기 전까지는 그
419
+ 컬럼의 정렬을 막아 둔다.
420
+ - **상태 문자열 → 색 매핑은 한 곳에 둔다** — `shared/lib/` 에 `statusTone(status)`
421
+ 같은 함수 하나를 두고 배지가 그것만 쓴다. 화면마다 삼항 연산으로 색을 고르면 같은
422
+ "미처리" 화면에 따라 다른 색이 된다. 모르는 값은 중립색으로 떨어뜨려 화면이
423
+ 깨지지 않게 한다.
424
+ - **떠 있는 패널(드롭다운·달력·빠른 동작)은 `Teleport` 여부를 "자르는 조상" 으로
425
+ 판단한다.** 카드처럼 `overflow` 로 자르는 조상 안에서 열리면 → `Teleport to="body"`
426
+ + 좌표 계산(안 그러면 패널이 잘린다). ② 헤더·사이드바처럼 자르는 조상이 없고 앵커에
427
+ 붙어 스크롤을 따라가야 하면 → Teleport 하지 않는다. **②의 경우 그 패널을 감싸는
428
+ 상자에 `overflow-hidden` 을 주지 않는다** — 모서리를 둥글리려고 무심코 준
429
+ `overflow-hidden` 하나가 패널을 통째로 안 보이게 만든다(모서리는 안쪽 면에 직접
430
+ 둥글리기를 준다).
431
+ - **트리거의 여닫기는 쓰는 쪽이 붙인다.** 드롭다운 블록은 열림 상태와 "바깥 클릭·Esc
432
+ 로 닫기" 만 갖고 `@click` 은 쓰는 쪽이 준다 — 상단 바처럼 여럿이 나란히 있을 때
433
+ "여는 쪽이 나머지를 닫는" 규칙을 블록이 대신 정해 버리면 그 규칙을 바꿀 수 없다.
434
+ - **셸(사이드바·상단 바)이 든 상태는 슬롯 프롭으로 내린다.** 레이아웃은 셸의 **부모**라
435
+ `provide`/`inject` 로는 셸이 든 상태(접힘·콘텐츠 넓이)를 받지 못한다 — `inject` 가
436
+ 조용히 `undefined` 가 되고 "코드는 맞는데 화면만 안 바뀐다" 로 나타난다.
437
+ - **사이드바 활성 판정은 "가장 긴 접두 하나" 다.** 정확 일치만 보면 상세 화면
438
+ (`/admin/members/1`)에서 아무 메뉴도 안 켜지고, 그냥 `startsWith` 면 `/admin/posts`
439
+ 와 `/admin/posts/trash` 가 **함께** 켜진다. 후보 중 현재 경로의 접두이면서 가장 긴
440
+ 것 하나만 켠다(경계는 세그먼트 단위 — `/admin/postscript` 가 `/admin/posts` 를 먹지
441
+ 않게). 루트 항목(`/admin`)은 접두로 이기지 않게 정확 일치로 둔다. 이 판정은 **앱이**
442
+ 한다 — 킷은 라우트를 모른다(결정 25·105).
443
+ - **shared 블록이 문구를 직접 번역하면 그 키는 전 앱 카탈로그에 있어야 한다** —
444
+ `shared/` 는 어느 앱 번들에도 실릴 수 있어서다. 한 앱에만 있으면 다른 앱 화면에서만
445
+ 키 문자열이 뜨는 조용한 실패가 된다(doctor **i18n-app-scope** 가 잡는다 ·
446
+ `agents/i18n.md`). 문구를 props 로 끌어올려 회피하지 말고 키를 복제한다.
447
+
448
+ **콘솔 블록 표면 정본 (프로젝트가 만들어 쓰는 확장 킷).** 아래는 관리 콘솔을 실제로
449
+ 만들며 굳은 표면이다. **`gaon new` 는 이것들을 심지 않는다** — 여러분 프로젝트의
450
+ `shared/components/ui/` 에 만들어야 있고, 만들 때 이 표면을 그대로 쓰면 화면 코드가
451
+ 프로젝트 사이에서 옮겨 다닌다. 이미 만들어져 있다면 **여러분 파일이 정본**이다(고쳤다면
452
+ 고친 쪽이 맞다). 값은 실제 `defineProps`·`defineEmits`·`<slot>` 에서 확인한 것이다.
453
+
454
+ *목록 화면.*
455
+
456
+ | 컴포넌트 | props (기본값) | slots | emits |
457
+ |---|---|---|---|
458
+ | **ListPage** | `title?` · `description?` · `total?: number`(편집 바 "N건") · `listTitle?`(목록 카드 제목) · `listHint?` · `asideWidth?: number` (`260`) | 기본(목록 본문) · `#actions` · `#aside` · `#filters` · `#filter-bulk` · `#filter-actions` · `#list-actions` · `#pagination` | `search` |
459
+ | **DataGrid** | `columns: GridColumn[]`(필수) · `rows: Record<string,unknown>[]`(필수) · `rowKey: string`(필수) · `selectable?` (`false`) · `selected?: string[]` (`[]`) · `actions?: 'none'\|'detail'\|'full'` (`'none'`) · `labelKey?` · `sortKey?` · `sortDir?: 'asc'\|'desc'` (`'asc'`) · `minWidth?: number` (`640`) | `#cell-<컬럼키>`(셀마다) | `sort(key)` · `toggleAll(on)` · `toggleOne(id,on)` · `detail(row)` · `edit(row)` · `remove(row)` |
460
+ | **FilterBar** | `as?: string` (`'form'`) | 기본(필터 격자) · `#bulk`(아래 줄 좌) · `#actions`(아래 줄 우) | `submit` |
461
+ | **ListBulk** | `sizeLabel: string`(필수) · `selectedCount: number`(필수) · `statuses?: string[]`(없으면 셀렉트 자체가 안 나옴) | — | `update:sizeLabel` · `status(value)` · `remove` |
462
+ | **RowActions** | `detailOnly?` (`false`) · `label?`(스크린리더용 대상 이름) | — | `detail` · `edit` · `remove` |
463
+ | **UserCell** | `user: UserCellUser`(필수) · `interactive?` (`true`) · `statusOptions?` · `gradeOptions?` | — | `action(key, user, value?)` |
464
+ | **TitleCell** | `title: string`(필수) · `prefix?`(앞 배지) · `prefixTone?: 'accent'\|'danger'\|'warning'\|'neutral'` (`'accent'`) · `count?`·`likes?`·`files?` (`0` · 0이면 안 나옴) · `depth?` (`0`) · `secret?` (`false`) | — | — |
465
+
466
+ `GridColumn` = `{ key, label, w?, num?, mono?, hideMobile?, noSort? }` — `w` 는
467
+ `grid-template-columns` 조각(없으면 `minmax(0,1fr)`) · `num` 은 우측 정렬 + `tabular-nums`
468
+ · `hideMobile` 은 좁은 화면 숨김 · `noSort` 는 정렬 헤더를 막는다(서버 페이지네이션).
469
+
470
+ *설정·상세 화면.*
471
+
472
+ | 컴포넌트 | props (기본값) | slots | emits |
473
+ |---|---|---|---|
474
+ | **Splitter** | `width?: number` (`230`) · `minLeft?` (`230`) · `minRight?` (`220`) · `height?: string`(비우면 화면 높이) · `stackAt?: number` (`768`) | `#left` · `#right` | `update:width` |
475
+ | **TreeView** | `nodes: TreeNode[]`(필수) · `title?` · `selected?` · `addLabel?: string\|null`(null 이면 추가 버튼 숨김) · `searchPlaceholder?` · `totalLabel?` · `editable?` (`false`) · `reorderable?` (`false`) | — | `select(node, path)` · `add` · `edit(node, depth)` · `reorder(from, to, 'before'\|'after')` |
476
+ | **DetailFilter** | `selects: DetailSelect[]`(필수) · `values: string[]`(필수) · `range: DateRange`(필수) · `query: string`(필수) · `searchPlaceholder: string`(필수) | — | `update:values` · `update:range` · `update:query` · `reset` · `search` |
477
+ | **PermissionMatrix** | `modelValue: Record<string,string>`(필수) · `rows: MatrixRow[]`(필수 · `{key,label,desc}` — **`desc` 도 필수**) · `targets: string[]`(필수) · `disabled?` | — | `update:modelValue` |
478
+ | **SettingRow** | `setting: Setting`(필수) · `typeLabel: string`(필수) · `editMode?` (`false`) | — | `change` · `revert` · `remove` · `copyKey` · `toggleScope` |
479
+
480
+ *컨트롤·표시.*
481
+
482
+ | 컴포넌트 | props (기본값) | slots | emits |
483
+ |---|---|---|---|
484
+ | **FloatingField** | `label: string`(필수) · `modelValue?: string\|number` · `control?: 'input'\|'textarea'\|'select'` (`'input'`) · `type?` (`'text'`) · `rows?` (`4`) · `error?` · `hint?` · `surface?: 'background'\|'card'\|'popover'` (`'background'`) | 기본(select 의 `<option>`) | `update:modelValue` |
485
+ | **DateRangeField** | `modelValue?: DateRange` · `label?` · `surface?` (`'card'`) | — | `update:modelValue` |
486
+ | **Segmented** | `modelValue: string`(필수) · `options: {value,label}[]`(필수) · `label?` · `disabled?` | — | `update:modelValue` |
487
+ | **Switch** | `modelValue?: boolean` · `label?` · `disabled?` | — | `update:modelValue` |
488
+ | **Checkbox** | `modelValue?: boolean` · `label?` | 기본 | `update:modelValue` |
489
+ | **Select** / **Textarea** | `modelValue?` (Textarea 는 `rows?` 추가) | Select: 기본(`<option>`) | `update:modelValue` |
490
+ | **Icon** | `name: string`(필수 · 레지스트리 키) · `size?: number` (`15`) · `strokeWidth?: number` (`1.75`) | — | — |
491
+ | **Avatar** | `name: string`(필수) · `src?` · `size?: 'sm'\|'default'\|'lg'` (`'default'`) | — | — |
492
+ | **StatCard** | `label: string`·`value: string`(필수) · `delta?` · `trend?: 'up'\|'down'\|'flat'` (`'flat'`) · `icon?` · `clickable?` (`false`) | — | — |
493
+ | **Tooltip** | `text: string`(필수) · `side?: 'top'\|'bottom'` (`'top'`) | 기본 | — |
494
+ | **Skeleton** | `variant?: 'text'\|'block'\|'circle'` (`'block'`) | — | — |
495
+ | **Spinner** | `size?: 'sm'\|'default'\|'lg'` (`'default'`) · `label?` | — | — |
496
+
497
+ *떠 있는 것 · 셸.*
498
+
499
+ | 컴포넌트 | props (기본값) | slots | emits |
500
+ |---|---|---|---|
501
+ | **Dropdown** | `open: boolean`(필수) · `align?: 'left'\|'right'` (`'right'`) · `up?` (`false`) · `width?` (`'192px'`) · `label?` | `#trigger`(**여닫기 `@click` 은 여기에 직접 붙인다**) · 기본(패널 내용) | `update:open` |
502
+ | **ConfirmDialog** | `open: boolean`·`title: string`(필수) · `description?` · `confirmLabel?` · `cancelLabel?` | — | `update:open` · `confirm` |
503
+ | **Toast** | `title: string`(필수) · `description?` · `variant?: 'default'\|'success'\|'warning'\|'destructive'` (`'default'`) | `#action` | — |
504
+ | **Banner** | `title: string`(필수) · `description?` · `tone?: 'neutral'\|'accent'\|'success'\|'warning'\|'danger'\|'info'` (`'info'`) · `action?`(없으면 링크 자체가 안 나옴) · `dismissible?` (`false`) | — | `action` · `dismiss` |
505
+ | **Editor** | `modelValue?: string`(HTML) · `placeholder?` · `minHeight?: number` (`220`) | — | `update:modelValue` |
506
+ | **Tabs** | `modelValue: string`(필수) | 기본(TabList·TabPanel 조합) | `update:modelValue` |
507
+ | **Collapse** | `open: boolean`(필수) | 기본 | — |
508
+ | **AdminShell** | `size?: 'default'\|'wide'\|'shell'\|'full'` (`'shell'`) · `align?: 'center'\|'start'` (`'start'`) · `sidebar?: 'expanded'\|'mini'\|'off'` (`'expanded'`) · `collapsible?` (`true`) | 기본 · `#brand`(슬롯 프롭 `{mini}`) · `#nav` · `#topbar` · `#topbar-actions`(슬롯 프롭 `{fullWidth,setFullWidth}`) · `#account` · `#sidebar-footer` · `#footer` | — |
509
+
510
+ *레이아웃 원자(간격을 값이 아니라 이름으로 준다).*
511
+
512
+ | 컴포넌트 | props (기본값) |
513
+ |---|---|
514
+ | **Container** | `size?: 'narrow'\|'default'\|'wide'\|'shell'\|'full'` (`'default'`) · `gutter?` (`true`) · `align?: 'center'\|'start'` (`'center'`) |
515
+ | **Stack** | `gap?: 'none'\|'inline'\|'stack'\|'group'\|'section'` (`'stack'`) · `as?` (`'div'`) · `align?` (`'stretch'`) |
516
+ | **Cluster** | `gap?: 'none'\|'inline'\|'stack'\|'group'` (`'inline'`) · `justify?: 'start'\|'center'\|'end'\|'between'` (`'start'`) · `align?` (`'center'`) · `wrap?` (`true`) |
517
+ | **Grid** | `cols?: 'auto'\|1\|2\|3\|4` (`'auto'`) · `gap?` (`'group'`) |
518
+ | **Section** | `gap?: 'stack'\|'group'\|'section'` (`'group'`) · `label?` |
519
+ | **Separator** | `orientation?: 'horizontal'\|'vertical'` (`'horizontal'`) · `decorative?` (`true`) |
520
+
521
+ *함께 쓰는 컴포저블 (`shared/lib/`).*
522
+
523
+ | 이름 | 인자 | 반환 |
524
+ |---|---|---|
525
+ | **`useSort(rows)`** | `ComputedRef<T[]>`(필터까지 끝난 행) | `{ sortKey, sortDir, sorted, toggle(key) }` — 같은 키 재클릭이면 방향만 뒤집는다 |
526
+ | **`useListPage({ rows, key, size? })`** | `rows`=정렬까지 끝난 행 · `key(row)=>string` · `size`=초기 표시 개수 | `{ page, sizeLabel, pageSize, pageCount, paged, selected, allChecked, selectionNote, toggleAll, toggleOne, clear }` |
527
+ | **`statusTone(status)`** | 상태 문자열 | `Tone` — 모르는 값은 `neutral` 로 떨어진다 |
528
+ | **`useToast()`** | — | `{ toasts, toast, success, warning, error, dismiss }` — 렌더는 레이아웃에 한 번 둔 `ToastRegion` 이 한다 |
529
+
530
+ **조립 순서(외우기).** `ListPage`(골격) → `#filters` 에 `FloatingField`·`DateRangeField` →
531
+ `#filter-bulk` 에 `ListBulk` → 본문에 `DataGrid`(셀은 `#cell-<키>` 로 `UserCell`·
532
+ `TitleCell`·`Badge`) → `#pagination` 에 `Pagination`. 상태는
533
+ `useSort` → `useListPage` 순서로 감는다(정렬한 뒤 쪽을 자른다 · 반대로 하면 현재 쪽
534
+ 안에서만 정렬된다).
535
+
536
+ **킷 사용법 문서는 데이터로 두고 화면이 렌더한다.** 블록이 열 개를 넘어가면 "이게 무슨
537
+ 컴포넌트인지" 를 매번 소스에서 읽게 된다. 항목(설명·props·사용 예·규칙)을 **모듈 하나**에
538
+ 모으고 그것을 렌더하는 페이지를 하나 두면, 컴포넌트를 더할 때 항목만 더하면 된다.
539
+ 그 페이지에는 **실물 미리보기**를 함께 낸다 — 살아 있는 컴포넌트를 그대로 렌더하면
540
+ 컴포넌트가 바뀔 때 미리보기도 같이 바뀌어 문서가 늙지 않는다(스크린샷은 늙는다).
541
+ props 표에는 **실제 `defineProps` 에서 확인한 것만** 적는다 — 없는 prop 을 그럴듯하게
542
+ 적어 두느니 행을 비우는 편이 낫다.
420
543
 
421
544
  ### 9. 클라이언트 환경변수 — `env` (결정 198 · F-9 옵션 ②)
422
545
 
@@ -539,6 +662,28 @@ async function runSearch(q: string) {
539
662
  (결정 37).
540
663
  - **`v-html` 은 XSS 탈출구** — 사용자 입력을 넣지 않는다
541
664
  (`agents/security.md`).
665
+ - **`gaon dev` 에 Vue HMR 은 없다 — 저장 후 브라우저를 새로고침한다.** dev 는 Vite
666
+ dev 서버를 띄우지 않고 **`vite build --watch`** 로 번들을 다시 만들며(앱마다 ·
667
+ 결정 146), 서버는 `dist/<앱>` 을 서빙한다. `.vue`·`.ts`·`theme/*.css` 를 고치면
668
+ 재빌드가 돌지만(실측 1.1~1.3초) **화면은 새로고침해야 바뀐다** — Inertia 탓이
669
+ 아니라 v1 어댑터에 Vite 통합이 없어서다(§6.4). "고쳤는데 화면이 그대로" 를 코드
670
+ 문제로 오진하지 말 것. 콘솔의 `[vite] built in …` 이 재빌드 완료 신호다.
671
+ - **`tailwind.config.ts` 를 고치면 `gaon dev` 를 재시작한다** — watch 프로세스가
672
+ Tailwind 설정을 물고 있어 **재빌드가 돌아도 새 유틸이 생성되지 않는다**(실측
673
+ 2026-08-07: `theme.extend.fontSize` 에 계단을 더했는데 `.text-<이름>` 이 CSS 에
674
+ 아예 안 나옴 → 클래스가 조용히 무효). 증상이 "클래스를 썼는데 스타일이 하나도
675
+ 안 먹는다" 라 오진하기 쉽다. **토큰 값(`theme/*.css`)만 고칠 때는 새로고침으로
676
+ 충분하고, 토큰↔유틸 매핑(설정)을 고칠 때만 재시작**한다.
677
+ - **웹폰트·정적 자산은 CSS 에서 상대경로로 참조한다** — `theme/fonts/*.woff2` 처럼
678
+ 두고 `@font-face { src: url('./fonts/…') }` 로 가리키면 Vite 가 해시해
679
+ `dist/<앱>/assets/` 로 내보내 코어의 `/assets/*` 서빙과 CSP `font-src 'self'` 를
680
+ 그대로 통과한다. **Vite `public/` 은 쓸 수 없고**(코어는 `dist/<앱>/assets/*` 만
681
+ 서빙 — dist 루트 산출물은 라우트가 없어 404), **`apps/<앱>/static/` 도
682
+ `index.html` 에서 참조하면 안 된다**(런타임은 서빙하지만 `gaon build` 산출
683
+ 검증(결정 146)이 "index.html 이 참조하는 에셋이 dist 에 없다" 로 막는다).
684
+ `index.html` 에서 스크립트를 하나 더 실어야 하면 **`<script type="module"
685
+ src="./x.js">`**(번들 대상)로 쓴다 — **인라인 `<script>` 는 기본 CSP
686
+ `script-src 'self'` 가 차단한다**(규칙 8 · 콘솔 위반).
542
687
 
543
688
  ## 관련 결정 번호
544
689
 
@@ -570,6 +715,7 @@ async function runSearch(q: string) {
570
715
  | 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
571
716
  | 결정 213 | (구) i18n Vue 소비 = 서버 주도 render props/sharedProps 만 — **결정 454 로 부분 번복**(탈출구로 존속) |
572
717
  | 결정 454 | 클라 `t()` 신설(`gaonjs/vue`) · 카탈로그 backend/frontend 분리 · 두 갈래 키 타입 · 앱별 카탈로그 청크(`agents/i18n.md`) |
718
+ | 결정 459 | locale 카탈로그 소유자 2개로 재편(454 부분 반전) — 루트 공용 `locales/` 폐지 · 화면 문구 = `apps/<앱>/locales/<로케일>/frontend.json`(앱마다 필수) · 공유 문구는 앱마다 키 복제(doctor i18n-app-scope · `agents/i18n.md`) |
573
719
  | 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §4) |
574
720
  | 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
575
721
  | 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.65.0",
3
+ "version": "0.65.3",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,13 +32,13 @@
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "typescript": "^5.9.0",
34
34
  "vite": "^7.0.0",
35
- "@gaonjs/async": "0.22.0",
36
- "@gaonjs/config": "0.26.0",
37
35
  "@gaonjs/core": "0.3.0",
38
- "@gaonjs/i18n": "0.5.0",
39
- "@gaonjs/mail": "0.5.4",
36
+ "@gaonjs/async": "0.22.0",
37
+ "@gaonjs/config": "0.26.1",
40
38
  "@gaonjs/web": "0.33.0",
41
- "@gaonjs/data": "0.26.2"
39
+ "@gaonjs/mail": "0.5.4",
40
+ "@gaonjs/i18n": "0.5.0",
41
+ "@gaonjs/data": "0.26.3"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.rmSync('dist/templates/project',{recursive:true,force:true});fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""