@gaonjs/cli 0.64.0 → 0.65.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/commands/check.js +34 -18
- package/dist/commands/g.d.ts +1 -0
- package/dist/commands/g.js +11 -2
- package/dist/commands/gen.js +1 -1
- package/dist/dev.d.ts +13 -7
- package/dist/dev.js +30 -22
- package/dist/doctor/fixers/i18n-layout.d.ts +2 -2
- package/dist/doctor/fixers/i18n-layout.js +119 -25
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +13 -3
- package/dist/doctor/fixers/types.d.ts +17 -2
- package/dist/doctor/i18n-app-scope.d.ts +3 -0
- package/dist/doctor/i18n-app-scope.js +94 -65
- package/dist/doctor/i18n-layout.d.ts +1 -1
- package/dist/doctor/i18n-layout.js +157 -32
- package/dist/doctor/i18n-server-scope.d.ts +2 -0
- package/dist/doctor/i18n-server-scope.js +124 -0
- package/dist/doctor/locale-parity.js +29 -68
- package/dist/doctor/redundant-index.d.ts +7 -0
- package/dist/doctor/redundant-index.js +67 -0
- package/dist/doctor/schema-relations.d.ts +13 -0
- package/dist/doctor/schema-relations.js +5 -1
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +42 -10
- package/dist/i18n-config.d.ts +9 -9
- package/dist/i18n-config.js +8 -15
- package/dist/messages-gen.d.ts +10 -10
- package/dist/messages-gen.js +42 -54
- package/dist/scaffold/app.d.ts +10 -1
- package/dist/scaffold/app.js +12 -2
- package/dist/templates/project/AGENTS.md.tpl +8 -6
- package/dist/templates/project/agents/data.md.tpl +6 -0
- package/dist/templates/project/agents/frontend.md.tpl +218 -75
- package/dist/templates/project/agents/i18n.md.tpl +165 -70
- package/dist/templates/project/agents/web.md.tpl +4 -3
- package/dist/templates/project/{locales → apps/web/locales}/en/frontend.json.tpl +1 -0
- package/dist/templates/project/{locales → apps/web/locales}/ko/frontend.json.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +6 -5
- package/package.json +7 -7
- package/dist/templates/project/apps/web/locales/en.json.tpl +0 -3
- package/dist/templates/project/apps/web/locales/ko.json.tpl +0 -3
- /package/dist/templates/project/{locales → domain/locales}/en/backend.json.tpl +0 -0
- /package/dist/templates/project/{locales → domain/locales}/ko/backend.json.tpl +0 -0
|
@@ -9,18 +9,17 @@
|
|
|
9
9
|
// 경고(error 아님): 부분 누락은 fallback 으로 화면이 깨지진 않는 소프트 결함이라
|
|
10
10
|
// 빌드를 세우지 않는다. --json 은 detail.locale·detail.missing 으로 구조화한다.
|
|
11
11
|
//
|
|
12
|
-
// 결정 456: **앱 스코프
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// 비교는
|
|
19
|
-
//
|
|
20
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
// 결정 456: **앱 스코프 카탈로그까지 훑는다.** `i18n-app-scope` 는 대신 못 잡는다:
|
|
13
|
+
// 그 검사의 키 집합은 **전 로케일 합집합**이라 en 에만 있는 키도 "있는 키" 로 세기
|
|
14
|
+
// 때문이다(축이 다르다 — 앱 경계 침범 검출).
|
|
15
|
+
//
|
|
16
|
+
// 결정 459: 소유자가 domain / 앱 N개로 재편되면서 비교 단위가 **3종**이 된다:
|
|
17
|
+
// ① domain-backend 로케일 간 ② 앱별 frontend 로케일 간 ③ 앱별 backend 로케일 간
|
|
18
|
+
// **스코프·소유자를 가로지르는 비교는 하지 않는다** — 앱 전용 키가 전량 "도메인에 없음"
|
|
19
|
+
// 으로 뜨는 오탐이 되고(결정 456 이 이미 기각한 축), frontend↔backend 는 애초에 다른 축이다.
|
|
21
20
|
import { join, relative } from 'node:path';
|
|
22
|
-
import {
|
|
23
|
-
import { analyzeProjectI18n
|
|
21
|
+
import { loadProjectLocales, flattenKeys } from '@gaonjs/i18n';
|
|
22
|
+
import { analyzeProjectI18n } from '../i18n-config.js';
|
|
24
23
|
// 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
|
|
25
24
|
const MAX_KEYS_SHOWN = 20;
|
|
26
25
|
// i18next 복수형 접미사(결정 181 · generator.ts 와 동일 규약). 로케일마다 필요한 접미사만
|
|
@@ -36,66 +35,27 @@ function pluralBase(key) {
|
|
|
36
35
|
* 비교 대상이 없어 건너뛴다(i18n 미사용·단일 로케일 프로젝트는 무소음).
|
|
37
36
|
*/
|
|
38
37
|
export async function checkLocaleParity(cwd) {
|
|
39
|
-
|
|
40
|
-
// 'locales' 는 dir 커스텀 프로젝트에서 이 검사를 무소음으로 껐다).
|
|
41
|
-
const localesDir = resolveLocalesDir(cwd, analyzeProjectI18n(cwd).dir);
|
|
42
|
-
if (!existsSync(localesDir))
|
|
38
|
+
if (!analyzeProjectI18n(cwd).declared)
|
|
43
39
|
return { rule: 'locale-parity', issues: [] };
|
|
44
|
-
|
|
45
|
-
// 스코프에 두면(ko 는 frontend, ja 는 backend) 클라 카탈로그가 갈리므로, 병합본으로
|
|
46
|
-
// 비교하면 그 배치 불일치를 못 본다. 파일 경로도 스코프별로 정확히 짚는다.
|
|
47
|
-
const scoped = loadScopedLocales(localesDir);
|
|
48
|
-
const issues = [];
|
|
49
|
-
for (const scope of ['backend', 'frontend']) {
|
|
50
|
-
const resources = scoped[scope];
|
|
51
|
-
issues.push(...parityIssues(cwd, localesDir, resources, scoped.layout, scope));
|
|
52
|
-
}
|
|
53
|
-
issues.push(...appParityIssues(cwd));
|
|
54
|
-
return { rule: 'locale-parity', issues };
|
|
55
|
-
}
|
|
56
|
-
/** `apps/` 아래 앱 폴더 이름들(없으면 빈 배열). */
|
|
57
|
-
function appNames(cwd) {
|
|
40
|
+
let project;
|
|
58
41
|
try {
|
|
59
|
-
|
|
60
|
-
.filter((e) => e.isDirectory())
|
|
61
|
-
.map((e) => e.name)
|
|
62
|
-
.sort();
|
|
42
|
+
project = loadProjectLocales(cwd);
|
|
63
43
|
}
|
|
64
44
|
catch {
|
|
65
|
-
|
|
45
|
+
// 로더 fail-loud 는 i18n-layout·check 가 소유한다 — 여기선 침묵(중복 진단 금지).
|
|
46
|
+
return { rule: 'locale-parity', issues: [] };
|
|
66
47
|
}
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* 결정 456: 앱 스코프 카탈로그의 로케일 간 키 diff. 앱 **내부에서만** 비교한다
|
|
70
|
-
* (앱 카탈로그는 루트 frontend 의 초과분이 정상이라 교차 비교는 전량 오탐).
|
|
71
|
-
* 앱 카탈로그는 frontend 전용이므로(결정 454 R7) 스코프 분리는 없다.
|
|
72
|
-
*/
|
|
73
|
-
function appParityIssues(cwd) {
|
|
74
48
|
const issues = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
for (const [
|
|
81
|
-
|
|
82
|
-
const shown = missing.slice(0, MAX_KEYS_SHOWN);
|
|
83
|
-
const more = missing.length - shown.length;
|
|
84
|
-
const list = shown.map((k) => `'${k}'`).join(', ') + (more > 0 ? ` …외 ${more}개` : '');
|
|
85
|
-
issues.push({
|
|
86
|
-
rule: 'locale-parity',
|
|
87
|
-
level: 'warning',
|
|
88
|
-
file: rel,
|
|
89
|
-
message: `로케일 '${lng}' 에 같은 앱의 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel}\n` +
|
|
90
|
-
` 누락 키: ${list}\n` +
|
|
91
|
-
`→ ${rel} 에 이 키들을 채우고 gaon gen 을 실행하세요 — 없으면 '${lng}' 사용자 화면에\n` +
|
|
92
|
-
` 키 문자열이 그대로 노출됩니다(결정 455 런타임 degrade). 앱 공용 문구면 대신\n` +
|
|
93
|
-
` locales/${lng}/frontend.json 으로 올리세요.`,
|
|
94
|
-
detail: { locale: lng, scope: `app:${app}`, app, missing },
|
|
95
|
-
});
|
|
49
|
+
// ① 도메인 — backend 만 정당하다(frontend 가 있으면 로더가 이미 throw).
|
|
50
|
+
issues.push(...parityIssues(cwd, join(cwd, 'domain', 'locales'), project.domain.backend, project.domain.layout, 'backend', 'domain'));
|
|
51
|
+
// ②③ 앱별 — frontend·backend 를 **각각** 그 앱 안에서만 비교한다.
|
|
52
|
+
for (const [app, scoped] of Object.entries(project.apps).sort(([a], [b]) => a.localeCompare(b))) {
|
|
53
|
+
const dir = join(cwd, 'apps', app, 'locales');
|
|
54
|
+
for (const scope of ['backend', 'frontend']) {
|
|
55
|
+
issues.push(...parityIssues(cwd, dir, scoped[scope], scoped.layout, scope, `app:${app}`));
|
|
96
56
|
}
|
|
97
57
|
}
|
|
98
|
-
return issues;
|
|
58
|
+
return { rule: 'locale-parity', issues };
|
|
99
59
|
}
|
|
100
60
|
/**
|
|
101
61
|
* 로케일 간 키 diff — 로케일별 "다른 로케일엔 있는데 나에겐 없는 키" 를 낸다.
|
|
@@ -123,8 +83,8 @@ function missingByLang(resources) {
|
|
|
123
83
|
}
|
|
124
84
|
return out;
|
|
125
85
|
}
|
|
126
|
-
/** 한 스코프 안에서 로케일 간 키 diff 를 계산한다(레거시 단일
|
|
127
|
-
function parityIssues(cwd, localesDir, resources, layout, scope) {
|
|
86
|
+
/** 한 소유자의 한 스코프 안에서 로케일 간 키 diff 를 계산한다(레거시 단일 파일 경로 포함). */
|
|
87
|
+
function parityIssues(cwd, localesDir, resources, layout, scope, owner) {
|
|
128
88
|
const issues = [];
|
|
129
89
|
for (const [lng, missing] of missingByLang(resources)) {
|
|
130
90
|
// 파일 경로는 레이아웃에 맞춘다 — 폴더 분리면 <lng>/<scope>.json, 레거시면 <lng>.json.
|
|
@@ -138,11 +98,12 @@ function parityIssues(cwd, localesDir, resources, layout, scope) {
|
|
|
138
98
|
rule: 'locale-parity',
|
|
139
99
|
level: 'warning',
|
|
140
100
|
file: rel,
|
|
141
|
-
message: `로케일 '${lng}' 에 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel}\n` +
|
|
101
|
+
message: `로케일 '${lng}' 에 같은 스코프의 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel} (${owner})\n` +
|
|
142
102
|
` 누락 키: ${list}\n` +
|
|
143
103
|
`→ ${rel} 에 이 키들을 채우세요 — 없으면 '${lng}' 사용자에게 fallback(대개 다른 언어)\n` +
|
|
144
|
-
` 번역이 조용히
|
|
145
|
-
|
|
104
|
+
` 번역이 조용히 노출되거나(서버), 키 문자열이 그대로 뜹니다(클라 · 결정 455).\n` +
|
|
105
|
+
` messages.d.ts 는 기준 로케일 기준이라 컴파일로 못 잡습니다(결정 216).`,
|
|
106
|
+
detail: { locale: lng, scope, owner, missing },
|
|
146
107
|
});
|
|
147
108
|
}
|
|
148
109
|
return issues;
|
|
@@ -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');
|
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-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';
|
|
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';
|
|
@@ -27,6 +28,7 @@ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-bu
|
|
|
27
28
|
export { checkLocaleParity } from './doctor/locale-parity.js';
|
|
28
29
|
export { checkI18nLayout } from './doctor/i18n-layout.js';
|
|
29
30
|
export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
|
|
31
|
+
export { checkI18nServerScope } from './doctor/i18n-server-scope.js';
|
|
30
32
|
export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
|
|
31
33
|
export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
|
|
32
34
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
@@ -35,7 +37,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
35
37
|
* 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
|
|
36
38
|
*/
|
|
37
39
|
/**
|
|
38
|
-
* doctor 정적 검사
|
|
40
|
+
* doctor 정적 검사 37종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
39
41
|
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
40
42
|
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
41
43
|
*/
|
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
|
+
* 검사를 조립한다(수는 ALL_RULES 가 단일 출처 — CLI help·AGENTS §2.2 가 그걸 읽는다):
|
|
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 규칙)
|
|
@@ -30,13 +30,16 @@
|
|
|
30
30
|
* 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
|
|
31
31
|
* 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
|
|
32
32
|
* 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
|
|
33
|
-
* 34) i18n-layout (결정 454 · 레거시 단일 파일 = 클라 t() 불가 안내 · --fix 로 폴더 이관)
|
|
34
|
-
* 35) i18n-app-scope (결정 454 · 앱 카탈로그에 없는 클라 t() 키 = 런타임 키 노출)
|
|
35
33
|
* 28) render-return (결정 340 · this.render/redirect/json 호출만 하고 return 누락 = 무신호 204 경고)
|
|
36
34
|
* 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
|
|
37
35
|
* 30) channel-instance-authorize (결정 440 · authorize 없는 인스턴스 채널 = 임의 인스턴스 공개 입장 경고)
|
|
38
36
|
* 31) dotenv-node-env (결정 430 · 공유 .env 의 NODE_ENV = 모드 누출 경고)
|
|
39
37
|
* 32) page-fetch (결정 453 · 세션 앱 .vue 의 내부 경로 raw fetch = CSRF 미부착 403 경고)
|
|
38
|
+
* 33) i18n-layout (결정 454·459 · 옛 카탈로그 배치 = 안 읽히는 폴더 안내 · --fix 로 이관)
|
|
39
|
+
* 34) i18n-app-scope (결정 454·459 · 그 앱 카탈로그에 없는 클라 t() 키 = 런타임 키 노출 ·
|
|
40
|
+
* shared/ 가 쓰는 키는 전 앱에 있어야 한다)
|
|
41
|
+
* 35) i18n-server-scope (결정 459 · 소유자 경계를 넘는 서버 t() 키 = 워커·크론에서 조용히 빔)
|
|
42
|
+
* 36) redundant-index (결정 460 · unique·PK 컬럼의 `.index()` = DDL 이 조용히 건너뜀 경고)
|
|
40
43
|
*
|
|
41
44
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
42
45
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -45,9 +48,9 @@
|
|
|
45
48
|
* 하위 호환: 기존 응답 혼용 API(`inspectControllerSource`·`runDoctor`)는
|
|
46
49
|
* 그대로 export — 기존 테스트가 계속 동작한다.
|
|
47
50
|
*/
|
|
48
|
-
import { rename, writeFile } from 'node:fs/promises';
|
|
51
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
49
52
|
import { existsSync } from 'node:fs';
|
|
50
|
-
import { join, resolve } from 'node:path';
|
|
53
|
+
import { dirname, join, resolve } from 'node:path';
|
|
51
54
|
import ts from 'typescript';
|
|
52
55
|
import { checkResponseMixing } from './doctor/response-mixing.js';
|
|
53
56
|
import { checkRenderReturn } from './doctor/render-return.js';
|
|
@@ -55,6 +58,7 @@ import { checkNPlusOne } from './doctor/n-plus-one.js';
|
|
|
55
58
|
import { checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
56
59
|
import { checkConnections } from './doctor/connections.js';
|
|
57
60
|
import { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
61
|
+
import { checkRedundantIndex } from './doctor/redundant-index.js';
|
|
58
62
|
import { checkMigrationDiff } from './doctor/migration-diff.js';
|
|
59
63
|
import { checkSharedPurity } from './doctor/shared-purity.js';
|
|
60
64
|
import { checkNoAutoImport } from './doctor/no-auto-import.js';
|
|
@@ -80,6 +84,7 @@ import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
|
|
|
80
84
|
import { checkLocaleParity } from './doctor/locale-parity.js';
|
|
81
85
|
import { checkI18nLayout } from './doctor/i18n-layout.js';
|
|
82
86
|
import { checkI18nAppScope } from './doctor/i18n-app-scope.js';
|
|
87
|
+
import { checkI18nServerScope } from './doctor/i18n-server-scope.js';
|
|
83
88
|
import { checkChannelCollision } from './doctor/channel-collision.js';
|
|
84
89
|
import { checkChannelInstanceAuthorize } from './doctor/channel-instance-authorize.js';
|
|
85
90
|
import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
|
|
@@ -94,6 +99,7 @@ export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one
|
|
|
94
99
|
export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
95
100
|
export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
|
|
96
101
|
export { checkSchemaRelations } from './doctor/schema-relations.js';
|
|
102
|
+
export { checkRedundantIndex } from './doctor/redundant-index.js';
|
|
97
103
|
export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
|
|
98
104
|
export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
|
|
99
105
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
@@ -114,6 +120,7 @@ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-bu
|
|
|
114
120
|
export { checkLocaleParity } from './doctor/locale-parity.js';
|
|
115
121
|
export { checkI18nLayout } from './doctor/i18n-layout.js';
|
|
116
122
|
export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
|
|
123
|
+
export { checkI18nServerScope } from './doctor/i18n-server-scope.js';
|
|
117
124
|
export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
|
|
118
125
|
export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
|
|
119
126
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
@@ -122,7 +129,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
122
129
|
* 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
|
|
123
130
|
*/
|
|
124
131
|
/**
|
|
125
|
-
* doctor 정적 검사
|
|
132
|
+
* doctor 정적 검사 37종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
126
133
|
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
127
134
|
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
128
135
|
*/
|
|
@@ -162,6 +169,8 @@ export const ALL_RULES = [
|
|
|
162
169
|
'page-fetch',
|
|
163
170
|
'i18n-layout',
|
|
164
171
|
'i18n-app-scope',
|
|
172
|
+
'i18n-server-scope',
|
|
173
|
+
'redundant-index',
|
|
165
174
|
];
|
|
166
175
|
/**
|
|
167
176
|
* `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
|
|
@@ -199,12 +208,14 @@ export const RULE_SUMMARIES = {
|
|
|
199
208
|
'no-import-meta-env': 'import.meta.env',
|
|
200
209
|
'locale-parity': '로케일 커버리지',
|
|
201
210
|
'i18n-layout': 'i18n 카탈로그 레이아웃(단일 파일 → backend/frontend 분리 안내 · 결정 454)',
|
|
202
|
-
'i18n-app-scope': '앱 경계를 넘는 클라 번역 키(그 앱 카탈로그에 없는 t() 키 · 결정 454)',
|
|
211
|
+
'i18n-app-scope': '앱 경계를 넘는 클라 번역 키(그 앱 카탈로그에 없는 t() 키 · 결정 454·459)',
|
|
212
|
+
'i18n-server-scope': '소유자 경계를 넘는 서버 번역 키(도메인이 앱 키를 쓰면 워커에서 빔 · 결정 459)',
|
|
203
213
|
'render-return': 'render return 누락',
|
|
204
214
|
'channel-collision': '앱간 동명 채널',
|
|
205
215
|
'channel-instance-authorize': '인스턴스 채널 authorize',
|
|
206
216
|
'dotenv-node-env': '.env NODE_ENV',
|
|
207
217
|
'page-fetch': '세션 앱 raw fetch',
|
|
218
|
+
'redundant-index': 'unique·PK 컬럼의 무시되는 .index()',
|
|
208
219
|
};
|
|
209
220
|
const CHECKERS = {
|
|
210
221
|
'response-mixing': checkResponseMixing,
|
|
@@ -237,6 +248,8 @@ const CHECKERS = {
|
|
|
237
248
|
'locale-parity': checkLocaleParity,
|
|
238
249
|
'i18n-layout': checkI18nLayout,
|
|
239
250
|
'i18n-app-scope': checkI18nAppScope,
|
|
251
|
+
'i18n-server-scope': checkI18nServerScope,
|
|
252
|
+
'redundant-index': checkRedundantIndex,
|
|
240
253
|
'render-return': checkRenderReturn,
|
|
241
254
|
'channel-collision': checkChannelCollision,
|
|
242
255
|
'channel-instance-authorize': checkChannelInstanceAuthorize,
|
|
@@ -435,6 +448,9 @@ export async function runDoctorFix(reports, cwd, apply) {
|
|
|
435
448
|
await writeFile(join(cwd, ref.file), ref.after, 'utf8');
|
|
436
449
|
backups.push(bak);
|
|
437
450
|
}
|
|
451
|
+
// 이관 대상은 새 폴더일 수 있다(예 locales/ko.json → domain/locales/ko/backend.json)
|
|
452
|
+
// — 부모가 없으면 rename 이 ENOENT 로 죽는다(결정 459).
|
|
453
|
+
await mkdir(dirname(join(cwd, plan.to)), { recursive: true });
|
|
438
454
|
await rename(join(cwd, plan.file), join(cwd, plan.to));
|
|
439
455
|
outcomes.push({
|
|
440
456
|
rule: report.rule,
|
|
@@ -445,17 +461,33 @@ export async function runDoctorFix(reports, cwd, apply) {
|
|
|
445
461
|
});
|
|
446
462
|
continue;
|
|
447
463
|
}
|
|
448
|
-
|
|
464
|
+
if (plan.kind === 'remove') {
|
|
465
|
+
// 결정 459: 원본을 백업한 뒤 지운다(복제가 이미 끝난 이관의 마지막 단계).
|
|
466
|
+
const backupRel = `${plan.file}.bak-${ts}`;
|
|
467
|
+
await writeFile(join(cwd, backupRel), plan.before, 'utf8');
|
|
468
|
+
await rm(join(cwd, plan.file), { force: true });
|
|
469
|
+
outcomes.push({
|
|
470
|
+
rule: report.rule,
|
|
471
|
+
file: plan.file,
|
|
472
|
+
summary: plan.summary,
|
|
473
|
+
applied: true,
|
|
474
|
+
backup: backupRel,
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
// 내용 재작성 — 원본 백업 먼저. 새 파일(before='')이면 부모 폴더를 만든다.
|
|
449
479
|
const abs = join(cwd, plan.file);
|
|
450
480
|
const backupRel = `${plan.file}.bak-${ts}`;
|
|
451
|
-
await
|
|
481
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
482
|
+
if (plan.before !== '')
|
|
483
|
+
await writeFile(join(cwd, backupRel), plan.before, 'utf8');
|
|
452
484
|
await writeFile(abs, plan.after, 'utf8');
|
|
453
485
|
outcomes.push({
|
|
454
486
|
rule: report.rule,
|
|
455
487
|
file: plan.file,
|
|
456
488
|
summary: plan.summary,
|
|
457
489
|
applied: true,
|
|
458
|
-
backup: backupRel,
|
|
490
|
+
backup: plan.before === '' ? undefined : backupRel,
|
|
459
491
|
});
|
|
460
492
|
}
|
|
461
493
|
catch (err) {
|
package/dist/i18n-config.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export interface ConfigI18nAnalysis {
|
|
2
2
|
/** defineConfig 인자에 i18n 프로퍼티가 존재하는가. */
|
|
3
3
|
readonly declared: boolean;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* 결정 459: `i18n.dir` 은 **제거된 설정**이다(카탈로그 위치는 관례 고정). 여기서는
|
|
6
|
+
* 더 이상 카탈로그를 찾는 데 쓰지 않고, **남아 있으면 doctor 가 error 로 잡기 위해서만**
|
|
7
|
+
* 읽는다(조용히 무시하면 "설정했는데 왜 안 먹지" 로 시간이 녹는다).
|
|
8
|
+
*/
|
|
5
9
|
readonly dir?: string;
|
|
6
10
|
/** 정적 리터럴로 읽힌 fallbackLng. 못 읽으면 undefined. */
|
|
7
11
|
readonly fallbackLng?: string;
|
|
@@ -18,15 +22,11 @@ export interface ConfigI18nAnalysis {
|
|
|
18
22
|
export declare function analyzeConfigI18n(source: string): ConfigI18nAnalysis;
|
|
19
23
|
/** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
|
|
20
24
|
export declare function analyzeProjectI18n(cwd: string): ConfigI18nAnalysis;
|
|
21
|
-
/**
|
|
22
|
-
* 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
|
|
23
|
-
* wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
|
|
24
|
-
* `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
|
|
25
|
-
*/
|
|
26
|
-
export declare function resolveLocalesDir(cwd: string, dir: string | undefined): string;
|
|
27
25
|
/**
|
|
28
26
|
* 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
|
|
29
|
-
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한
|
|
30
|
-
*
|
|
27
|
+
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 기준 로케일을 볼 수
|
|
28
|
+
* 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
|
|
29
|
+
* 결정 459: 카탈로그 **위치**는 관례 고정이라 `dir` 미해석은 더 이상 축을 갈라놓지 않는다
|
|
30
|
+
* (남아 있는 `dir` 자체는 doctor `i18n-layout` 이 error 로 잡는다) — fallbackLng 만 경고한다.
|
|
31
31
|
*/
|
|
32
32
|
export declare function warnUnresolvedI18n(analysis: ConfigI18nAnalysis, write: (s: string) => void): void;
|
package/dist/i18n-config.js
CHANGED
|
@@ -133,26 +133,19 @@ export function analyzeProjectI18n(cwd) {
|
|
|
133
133
|
return { declared: false, unresolved: [] };
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
-
/**
|
|
137
|
-
* 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
|
|
138
|
-
* wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
|
|
139
|
-
* `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
|
|
140
|
-
*/
|
|
141
|
-
export function resolveLocalesDir(cwd, dir) {
|
|
142
|
-
const d = dir ?? 'locales';
|
|
143
|
-
return d.startsWith('/') ? d : join(cwd, d);
|
|
144
|
-
}
|
|
145
136
|
/**
|
|
146
137
|
* 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
|
|
147
|
-
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한
|
|
148
|
-
*
|
|
138
|
+
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 기준 로케일을 볼 수
|
|
139
|
+
* 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
|
|
140
|
+
* 결정 459: 카탈로그 **위치**는 관례 고정이라 `dir` 미해석은 더 이상 축을 갈라놓지 않는다
|
|
141
|
+
* (남아 있는 `dir` 자체는 doctor `i18n-layout` 이 error 로 잡는다) — fallbackLng 만 경고한다.
|
|
149
142
|
*/
|
|
150
143
|
export function warnUnresolvedI18n(analysis, write) {
|
|
151
|
-
if (analysis.unresolved.
|
|
144
|
+
if (!analysis.unresolved.includes('fallbackLng'))
|
|
152
145
|
return;
|
|
153
|
-
write(` ! gaon.config.ts 의 i18n
|
|
154
|
-
`타입 축(.gaon/messages.d.ts)과 doctor 는 기본값(
|
|
155
|
-
` → 문자열 리터럴로 직접 쓰면 정확히 반영됩니다: i18n: {
|
|
146
|
+
write(` ! gaon.config.ts 의 i18n fallbackLng 를 정적으로 읽지 못했습니다 — ` +
|
|
147
|
+
`타입 축(.gaon/messages.d.ts)과 doctor 는 기본값(기준=정렬 첫 로케일)을 씁니다.\n` +
|
|
148
|
+
` → 문자열 리터럴로 직접 쓰면 정확히 반영됩니다: i18n: { fallbackLng: 'ko' }\n`);
|
|
156
149
|
}
|
|
157
150
|
function isDefineConfig(e) {
|
|
158
151
|
if (ts.isIdentifier(e) && e.text === 'defineConfig')
|
package/dist/messages-gen.d.ts
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ScopedLocaleResources } from '@gaonjs/i18n';
|
|
2
2
|
/** 메시지 축이 카탈로그 모듈을 낼 앱(결정 454). dev 레이아웃이 만들어 넘긴다. */
|
|
3
3
|
export interface MessagesApp {
|
|
4
4
|
readonly name: string;
|
|
5
|
-
/** apps/<앱>/locales (
|
|
5
|
+
/** apps/<앱>/locales (그 앱의 스코프 카탈로그 · 없어도 된다). */
|
|
6
6
|
readonly localesDir: string;
|
|
7
7
|
/** apps/<앱>/.gaon/messages.catalog.ts */
|
|
8
8
|
readonly catalogOut: string;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* locales
|
|
12
|
-
* 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
13
|
-
* i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
11
|
+
* 프로젝트 카탈로그(domain/locales · apps/*/locales)에서 `.gaon/messages.d.ts`(두 갈래)와
|
|
12
|
+
* 앱별 카탈로그 값 모듈을 생성한다. 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
13
|
+
* 비운 채로 둬 키가 string 폴백 — i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
14
|
+
* 생성 여부를 돌려준다.
|
|
14
15
|
*/
|
|
15
|
-
export declare function generateMessagesDts(
|
|
16
|
+
export declare function generateMessagesDts(root: string, out: string, baseLng?: string, apps?: readonly MessagesApp[]): boolean;
|
|
16
17
|
interface CatalogInput {
|
|
17
|
-
readonly localesDir: string;
|
|
18
18
|
readonly app: MessagesApp;
|
|
19
|
-
|
|
20
|
-
readonly
|
|
19
|
+
/** 그 앱의 스코프 카탈로그(없으면 undefined = locales 폴더 부재). */
|
|
20
|
+
readonly appScoped: ScopedLocaleResources | undefined;
|
|
21
21
|
readonly fallbackLng: string;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* 결정 454: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가
|
|
24
|
+
* 결정 454·459: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가 **그 앱의 frontend.json 하나**를
|
|
25
25
|
* 동적 import 해 vite 가 로케일별 해시 청크(.js)로 쪼갠다 — `/assets/*` immutable 캐시 대상이며
|
|
26
26
|
* `text/javascript` 라 seal 봉인 대상(application/json)에서 자연 면제된다.
|
|
27
27
|
* 생성 여부를 돌려준다(프론트 카탈로그가 없으면 false + 스테일 제거).
|