@gaonjs/cli 0.52.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,23 +15,64 @@ export function analyzeConfigI18n(source) {
15
15
  let declared = false;
16
16
  let dir;
17
17
  let fallbackLng;
18
+ let supportedLngs;
19
+ const unresolved = new Set();
20
+ // 결정 412: `as`·`satisfies` 래핑을 벗긴다 — defineConfig({...} satisfies GaonConfig)
21
+ // 는 실사용 형태인데 종전엔 객체 리터럴이 아니라고 보고 i18n 선언 자체를 놓쳤다.
22
+ const unwrap = (node) => {
23
+ let e = node;
24
+ while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
25
+ e = e.expression;
26
+ }
27
+ return e;
28
+ };
29
+ // 문자열 리터럴 + 치환 없는 템플릿 리터럴(`translations`)을 함께 읽는다 — 개발자
30
+ // 눈에는 똑같은 리터럴인데 종전엔 템플릿만 무소음으로 빠졌다(결정 412).
31
+ const literalText = (e) => {
32
+ const n = unwrap(e);
33
+ if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n))
34
+ return n.text;
35
+ return undefined;
36
+ };
18
37
  // i18n 초기화식에서 객체 리터럴을 찾는다 — 삼항·??·&&·괄호는 양변을 훑는다
19
38
  // (connections.ts 와 동일 규약 · env 조건부 블록 대응). 두 분기가 서로 다른
20
39
  // 리터럴을 주는 병리적 케이스는 먼저 읽힌 값을 쓴다(실사용 형태 아님).
21
- const collect = (node) => {
22
- if (ts.isParenthesizedExpression(node))
23
- return collect(node.expression);
40
+ const collect = (raw) => {
41
+ const node = unwrap(raw);
24
42
  if (ts.isObjectLiteralExpression(node)) {
25
43
  for (const p of node.properties) {
44
+ // 결정 412: shorthand(`{ i18n }`)·스프레드는 값을 여기서 알 수 없다 — 조용히
45
+ // 넘기지 않고 미해석으로 표시한다.
46
+ if (ts.isShorthandPropertyAssignment(p) || ts.isSpreadAssignment(p)) {
47
+ unresolved.add('dir');
48
+ unresolved.add('fallbackLng');
49
+ continue;
50
+ }
26
51
  if (!ts.isPropertyAssignment(p))
27
52
  continue;
28
53
  const name = propNameText(p.name);
29
- if (name === 'dir' && ts.isStringLiteral(p.initializer) && dir === undefined) {
30
- dir = p.initializer.text;
54
+ // 결정 413: supportedLngs 리터럴 배열일 때만 읽는다(check 정적 미러용).
55
+ if (name === 'supportedLngs' && supportedLngs === undefined) {
56
+ const arr = unwrap(p.initializer);
57
+ if (ts.isArrayLiteralExpression(arr)) {
58
+ const items = arr.elements.map((el) => literalText(el));
59
+ if (items.every((v) => v !== undefined))
60
+ supportedLngs = items;
61
+ }
62
+ continue;
31
63
  }
32
- if (name === 'fallbackLng' && ts.isStringLiteral(p.initializer) && fallbackLng === undefined) {
33
- fallbackLng = p.initializer.text;
64
+ if (name !== 'dir' && name !== 'fallbackLng')
65
+ continue;
66
+ const text = literalText(p.initializer);
67
+ if (text === undefined) {
68
+ // 변수 참조·env 표현식 등 — 런타임 실값과 갈라지는 지점.
69
+ unresolved.add(name);
70
+ continue;
34
71
  }
72
+ if (name === 'dir' && dir === undefined)
73
+ dir = text;
74
+ if (name === 'fallbackLng' && fallbackLng === undefined)
75
+ fallbackLng = text;
35
76
  }
36
77
  return;
37
78
  }
@@ -53,8 +94,16 @@ export function analyzeConfigI18n(source) {
53
94
  const visit = (node) => {
54
95
  if (ts.isCallExpression(node) && isDefineConfig(node.expression)) {
55
96
  const arg = node.arguments[0];
56
- if (arg && ts.isObjectLiteralExpression(arg)) {
57
- for (const p of arg.properties) {
97
+ const obj = arg ? unwrap(arg) : undefined;
98
+ if (obj && ts.isObjectLiteralExpression(obj)) {
99
+ for (const p of obj.properties) {
100
+ if (ts.isShorthandPropertyAssignment(p) && p.name.text === 'i18n') {
101
+ // `defineConfig({ i18n })` — 선언은 확실하나 값은 정적으로 못 읽는다.
102
+ declared = true;
103
+ unresolved.add('dir');
104
+ unresolved.add('fallbackLng');
105
+ continue;
106
+ }
58
107
  if (ts.isPropertyAssignment(p) && propNameText(p.name) === 'i18n') {
59
108
  declared = true;
60
109
  collect(p.initializer);
@@ -65,20 +114,46 @@ export function analyzeConfigI18n(source) {
65
114
  ts.forEachChild(node, visit);
66
115
  };
67
116
  visit(sf);
68
- return { declared, dir, fallbackLng };
117
+ // 읽힌 키는 미해석 목록에서 뺀다(한 분기만 리터럴인 경우 값이 있으면 그 값을 쓴다).
118
+ if (dir !== undefined)
119
+ unresolved.delete('dir');
120
+ if (fallbackLng !== undefined)
121
+ unresolved.delete('fallbackLng');
122
+ return { declared, dir, fallbackLng, supportedLngs, unresolved: [...unresolved] };
69
123
  }
70
124
  /** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
71
125
  export function analyzeProjectI18n(cwd) {
72
126
  const configPath = join(cwd, 'gaon.config.ts');
73
127
  if (!existsSync(configPath))
74
- return { declared: false };
128
+ return { declared: false, unresolved: [] };
75
129
  try {
76
130
  return analyzeConfigI18n(readFileSync(configPath, 'utf8'));
77
131
  }
78
132
  catch {
79
- return { declared: false };
133
+ return { declared: false, unresolved: [] };
80
134
  }
81
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
+ /**
146
+ * 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
147
+ * 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 카탈로그를 보거나
148
+ * 아예 안 생길 수 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
149
+ */
150
+ export function warnUnresolvedI18n(analysis, write) {
151
+ if (analysis.unresolved.length === 0)
152
+ return;
153
+ write(` ! gaon.config.ts 의 i18n ${analysis.unresolved.join('·')} 을(를) 정적으로 읽지 못했습니다 — ` +
154
+ `타입 축(.gaon/messages.d.ts)과 doctor 는 기본값(dir='locales' · 기준=정렬 첫 로케일)을 씁니다.\n` +
155
+ ` → 문자열 리터럴로 직접 쓰면 정확히 반영됩니다: i18n: { dir: 'locales', fallbackLng: 'ko' }\n`);
156
+ }
82
157
  function isDefineConfig(e) {
83
158
  if (ts.isIdentifier(e) && e.text === 'defineConfig')
84
159
  return true;
package/dist/index.js CHANGED
@@ -109,13 +109,15 @@ function renderHelp(version = VERSION) {
109
109
  " gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
110
110
  " gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
111
111
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
112
- " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
112
+ " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all)",
113
+ " gaon test -- <인자> `--` 뒤는 vitest 로 그대로 전달 (gaon 이 안 가로챔 · 예: gaon test -- --json --reporter=json)",
113
114
  ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지·render return 누락)`,
114
115
  " gaon doctor --json 자동화용 JSON 출력",
115
116
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
116
117
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
117
118
  " gaon doctor --fix --yes 실제 편집 적용(원본은 .bak-<타임스탬프> 로 자동 백업)",
118
- " gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트 · --jwt --app <api> = API 앱 토큰 변형)",
119
+ " gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트 · --jwt --app <api> = API 앱 토큰 변형 · --public = 비-web 앱 공개 가입)",
120
+ " gaon g ui-kit UI 킷 스캐폴드 (Button·Input 등 원자 프리미티브 · --app <이름>)",
119
121
  " gaon g controller <name> 컨트롤러 스캐폴드 (Rails 관례 · 페이지+JSON 액션)",
120
122
  " gaon g model <Name> 모델 스캐폴드 (스키마+모델 · E-4 컬럼 예시)",
121
123
  " gaon g page <Path/Name> Vue 페이지 (Inertia SPA · pageProps 브리지)",
@@ -152,14 +154,29 @@ export function parseDoctorChecks(argv) {
152
154
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
153
155
  const isKnown = (s) => ALL_RULES.includes(s);
154
156
  const out = [];
157
+ const unknown = [];
155
158
  for (const a of argv) {
156
159
  if (a.startsWith("--check=")) {
157
160
  for (const nm of a.slice("--check=".length).split(",")) {
158
- if (isKnown(nm) && !out.includes(nm))
159
- out.push(nm);
161
+ if (isKnown(nm)) {
162
+ if (!out.includes(nm))
163
+ out.push(nm);
164
+ }
165
+ else if (nm !== "" && !unknown.includes(nm)) {
166
+ unknown.push(nm);
167
+ }
160
168
  }
161
169
  }
162
170
  }
171
+ // 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
172
+ // 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
173
+ // 28종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
174
+ if (unknown.length > 0) {
175
+ process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
176
+ ` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
177
+ (out.length === 0 ? " (인정된 이름이 없어 전체 검사를 실행합니다)" : "") +
178
+ "\n");
179
+ }
163
180
  return out.length ? out : undefined;
164
181
  }
165
182
  /**
@@ -225,6 +242,20 @@ export function parseDbArgs(sub, argv) {
225
242
  const dbIdx = argv.indexOf("--db");
226
243
  const cfgIdx = argv.indexOf("--config");
227
244
  const valueFlags = new Set(["--db", "--config"]);
245
+ // 결정 404: 값 플래그의 **값 부재**도 fail-loud. `gaon db migrate --db`(값 없이 끝)는
246
+ // db=undefined 가 돼 "커넥션 하나만" 이던 의도가 조용히 **전 커넥션 적용**으로 확장됐다
247
+ // (결정 139 순회 기본값과 결합해 파괴 반경이 커진다). readPortFlag(결정 240)와 같은 규약.
248
+ for (const f of valueFlags) {
249
+ const i = argv.indexOf(f);
250
+ if (i < 0)
251
+ continue;
252
+ const v = argv[i + 1];
253
+ if (v === undefined || v.startsWith("-")) {
254
+ throw new Error(`gaon db ${sub}: ${f} 값이 없습니다.\n` +
255
+ ` → 예: gaon db ${sub} ${f} ${f === "--db" ? "main" : "gaon.config.ts"}\n` +
256
+ ` → 값 없이 두면 ${f === "--db" ? "전 커넥션에 적용" : "기본 설정 파일 사용"}으로 조용히 넓어져 멈춥니다.`);
257
+ }
258
+ }
228
259
  // 결정 358: db 는 파괴 방향 오동작이 가능한 명령군이라 미지 플래그를 조용히
229
260
  // 무시하지 않는다 — `--dryrun`(오타)이 무시되면 dry-run 의도가 **실제 마이그레이션
230
261
  // 적용**으로 반전된다(결정 267 down-위치 문제와 동형). 인정 집합 밖 `--*` 는 throw.
@@ -248,6 +279,19 @@ export function parseDbArgs(sub, argv) {
248
279
  }
249
280
  positionals.push(a);
250
281
  }
282
+ // 결정 404: 미지 **위치 인자**도 막는다. 종전엔 positionals 가 'down' 판정에만 쓰이고
283
+ // 나머지는 버려져서 `gaon db migrate donw`(오타)·`rollback`(타 프레임웍 관례)이 무경고로
284
+ // **정방향 migrate 실 적용**으로 반전됐다 — 결정 358 이 플래그에서 막은 것과 같은 파괴
285
+ // 방향인데 위치 인자만 뚫려 있었다.
286
+ const allowedPositionals = sub === "migrate" ? ["down"] : [];
287
+ const unknown = positionals.find((p) => !allowedPositionals.includes(p));
288
+ if (unknown !== undefined) {
289
+ throw new Error(`gaon db ${sub}: 알 수 없는 인자 '${unknown}'.\n` +
290
+ (sub === "migrate"
291
+ ? ` → 지원 인자: down(롤백) 하나뿐입니다. 예: gaon db migrate down\n`
292
+ : ` → gaon db ${sub} 는 위치 인자를 받지 않습니다.\n`) +
293
+ ` → 오타가 조용히 무시되면 의도와 반대로(예: 롤백 대신 정방향 적용) 실행될 수 있어 멈춥니다.`);
294
+ }
251
295
  return {
252
296
  json: argv.includes("--json"),
253
297
  db: dbIdx >= 0 ? argv[dbIdx + 1] : undefined,
@@ -372,9 +416,17 @@ export function runCli(argv, opts = {}) {
372
416
  const knownSteps = ["typecheck", "vue-tsc", "build", "doctor"];
373
417
  const onlyIdx = argv.indexOf("--only");
374
418
  const onlyRaw = onlyIdx >= 0 ? argv[onlyIdx + 1] : undefined;
375
- const only = onlyRaw && knownSteps.includes(onlyRaw)
376
- ? onlyRaw
377
- : undefined;
419
+ // 결정 411: `--only` 오타를 조용히 전체 실행으로 폴백하지 않는다 — "typecheck 만
420
+ // 돌렸다" 는 착각으로 CI 시간이 늘거나(안전 방향이라 더 안 보인다), 반대로
421
+ // 단일 단계만 돌린 줄 알고 넘어간다. 값 부재도 같이 막는다(--port 규약 · 결정 240).
422
+ if (onlyIdx >= 0 && (onlyRaw === undefined || !knownSteps.includes(onlyRaw))) {
423
+ process.stderr.write(` ✗ gaon check: --only 값이 ${onlyRaw === undefined ? "없습니다" : `잘못됐습니다('${onlyRaw}')`}.\n` +
424
+ ` → 지원 단계: ${knownSteps.join(" · ")}\n` +
425
+ ` → 예: gaon check --only typecheck\n`);
426
+ process.exitCode = 2;
427
+ return;
428
+ }
429
+ const only = onlyRaw !== undefined ? onlyRaw : undefined;
378
430
  void runCheckCommand({
379
431
  json: argv.includes("--json"),
380
432
  only,
@@ -437,9 +489,14 @@ export function runCli(argv, opts = {}) {
437
489
  // --fix (dry-run): errors > 0 이면 1 · errors 0 이어도 **계획이 있으면 1**
438
490
  // (고칠 게 있다는 신호 — 이전엔 warning-only 계획이 0 으로 삼켜짐).
439
491
  // 기본: errors > 0 이면 1.
492
+ // 결정 410: `--fix --yes` 인데 **적용에 성공한 게 하나도 없으면** 비-0.
493
+ // 종전엔 잔여 errors 만 봐서, 위반이 warning 레벨이고 fixer 가 전부 실패하면
494
+ // (fixer 예외·대상 파일 선점 등) "고친 것 0" 인데 exit 0 이라 자동화가
495
+ // "고쳐졌다" 로 오판했다 — 시도했는데 아무것도 못 고친 건 성공이 아니다.
440
496
  const fix = result.fix;
441
497
  const dryPlans = fix !== undefined && !fix.applied && fix.outcomes.length > 0;
442
- process.exitCode = result.errors.length > 0 || dryPlans ? 1 : 0;
498
+ const nothingApplied = fix !== undefined && fix.applied && fix.outcomes.length > 0 && !fix.outcomes.some((o) => o.applied);
499
+ process.exitCode = result.errors.length > 0 || dryPlans || nothingApplied ? 1 : 0;
443
500
  })
444
501
  .catch((err) => {
445
502
  const msg = err instanceof Error ? err.message : String(err);
@@ -505,21 +562,33 @@ export function runCli(argv, opts = {}) {
505
562
  if (argv[0] === "db") {
506
563
  const sub = argv[1];
507
564
  const known = ["diff", "migrate", "reset", "seed", "status"];
565
+ // 결정 411: --json 모드면 실패 사유도 **구조화**해서 낸다 — runDbCommand 는 이미
566
+ // JSON 에러를 방출하는데(commands/db.ts) 라우팅 단계 실패만 텍스트라 자동화가
567
+ // 사유를 못 받았다(비대칭).
568
+ const dbJson = argv.includes("--json");
569
+ const failDb = (message) => {
570
+ if (dbJson) {
571
+ process.stdout.write(JSON.stringify({ ok: false, kind: "usage", command: "db", message }) + "\n");
572
+ }
573
+ else {
574
+ process.stderr.write(` ✗ ${message}\n`);
575
+ }
576
+ process.exitCode = 1;
577
+ };
508
578
  if (!sub || !known.includes(sub)) {
509
- process.stderr.write(` ✗ 수 없는 db 서브커맨드: ${sub ?? "(없음)"}\n` +
579
+ failDb(`알 수 없는 db 서브커맨드: ${sub ?? "(없음)"}\n` +
510
580
  ` → 지원: gaon db diff | migrate | reset | seed | status\n` +
511
- ` → 옵션: --json · --db <키>(생략 = 전 커넥션 순회) · --config <path> · --yes · --dry-run\n`);
512
- process.exitCode = 1;
581
+ ` → 옵션: --json · --db <키>(생략 = 전 커넥션 순회) · --config <path> · --yes · --dry-run`);
513
582
  return;
514
583
  }
515
- // 결정 358: 미지 플래그는 파싱 경계에서 fail-loud(오타 dry-run 이 실 적용되는 반전 방지).
584
+ // 결정 358·404: 미지 플래그·미지 위치 인자·값 부재는 파싱 경계에서 fail-loud
585
+ // (오타 dry-run 이 실 적용되는 반전 방지).
516
586
  let dbOpts;
517
587
  try {
518
588
  dbOpts = parseDbArgs(sub, argv);
519
589
  }
520
590
  catch (err) {
521
- process.stderr.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`);
522
- process.exitCode = 1;
591
+ failDb(err instanceof Error ? err.message : String(err));
523
592
  return;
524
593
  }
525
594
  void runDbCommand(sub, dbOpts)
@@ -659,14 +728,27 @@ export function runCli(argv, opts = {}) {
659
728
  // `gaon test [--scope unit|integration|all] [-- vitest 인자]` — 테스트 러너(M9-G).
660
729
  if (argv[0] === "test") {
661
730
  const rest = argv.slice(1);
662
- const scopeIdx = rest.indexOf("--scope");
663
- const scopeRaw = scopeIdx >= 0 ? rest[scopeIdx + 1] : undefined;
664
- const scope = scopeRaw === "unit" || scopeRaw === "integration" || scopeRaw === "all"
665
- ? scopeRaw
666
- : undefined;
731
+ // 결정 411: `--` 뒤는 **전부 vitest 몫**이다. 종전엔 gaon 이 위치와 무관하게
732
+ // --json·--scope 가로채 `gaon test -- --json`(vitest 자체 JSON 리포터)이
733
+ // 불가능했다 탈출구를 연다(help 적힌 계약과 코드를 일치시킨다).
734
+ const sepIdx = rest.indexOf("--");
735
+ const own = sepIdx >= 0 ? rest.slice(0, sepIdx) : rest;
736
+ const forwarded = sepIdx >= 0 ? rest.slice(sepIdx + 1) : [];
737
+ const scopeIdx = own.indexOf("--scope");
738
+ const scopeRaw = scopeIdx >= 0 ? own[scopeIdx + 1] : undefined;
739
+ // 결정 411: scope 오타를 조용히 전체 실행으로 폴백하지 않는다(--pm 오타를 막은
740
+ // 결정 167 과 대칭) — 통합만 돌리려던 CI 가 전체를 돌리며 실 인프라까지 띄운다.
741
+ if (scopeIdx >= 0 && scopeRaw !== "unit" && scopeRaw !== "integration" && scopeRaw !== "all") {
742
+ process.stderr.write(` ✗ gaon test: --scope 값이 ${scopeRaw === undefined ? "없습니다" : `잘못됐습니다('${scopeRaw}')`}.\n` +
743
+ ` → 지원 값: unit · integration · all\n` +
744
+ ` → 예: gaon test --scope integration\n`);
745
+ process.exitCode = 2;
746
+ return;
747
+ }
748
+ const scope = scopeRaw !== undefined ? scopeRaw : undefined;
667
749
  const passthrough = [];
668
- for (let i = 0; i < rest.length; i++) {
669
- const a = rest[i];
750
+ for (let i = 0; i < own.length; i++) {
751
+ const a = own[i];
670
752
  if (a === "--scope") {
671
753
  i++;
672
754
  continue;
@@ -676,7 +758,8 @@ export function runCli(argv, opts = {}) {
676
758
  if (a !== undefined)
677
759
  passthrough.push(a);
678
760
  }
679
- void runTestCommand(passthrough, { json: argv.includes("--json"), scope })
761
+ passthrough.push(...forwarded);
762
+ void runTestCommand(passthrough, { json: own.includes("--json"), scope })
680
763
  .then((code) => {
681
764
  process.exitCode = code;
682
765
  })
package/dist/mcp/tools.js CHANGED
@@ -603,6 +603,10 @@ export const TOOLS = [
603
603
  description: '지정 시 그 검사 하나만 실행. 생략 시 전체.',
604
604
  },
605
605
  noDoctor: { type: 'boolean', description: 'true 면 doctor 를 제외(기본 false — doctor 는 기본 포함 · 결정 157).' },
606
+ // 결정 411: 폐기된 includeDoctor 를 스키마에 되살리지 **않는다**. 자리를 남기면
607
+ // strict 클라이언트의 구 호출이 "성공했는데 조용히 무시" 로 끝나 결정 362 가
608
+ // 없애려던 유령 파라미터 오도가 되살아난다 — 스키마 부재로 인한 검증 거부가
609
+ // 오히려 fail-loud(어느 파라미터를 쓰라는 신호)라 결정 362 계약을 유지한다.
606
610
  },
607
611
  additionalProperties: false,
608
612
  },
@@ -4,7 +4,7 @@
4
4
  // 카탈로그의 키를 유니온 타입으로 물성화해, t('key') 의 존재하지 않는 키를 컴파일
5
5
  // 타임에 잡는다(현재는 GaonMessages 가 비어 있어 키가 string 으로 열림). 생성 파일은
6
6
  // 타입만 담는다(규칙 3). @gaonjs/i18n 의 공개 API(loadLocales·renderMessagesDts)만 쓴다.
7
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { dirname } from 'node:path';
9
9
  import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
10
10
  /**
@@ -13,11 +13,19 @@ import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
13
13
  * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
14
14
  */
15
15
  export function generateMessagesDts(localesDir, out, baseLng) {
16
- if (!existsSync(localesDir))
16
+ // 결정 414: 카탈로그가 사라졌으면 **옛 생성물을 지운다** — 남겨두면 없어진 키의
17
+ // 유니온이 그대로 살아 t('없어진키') 가 계속 컴파일된다(제거를 못 잡는 사각).
18
+ if (!existsSync(localesDir)) {
19
+ if (existsSync(out))
20
+ rmSync(out, { force: true });
17
21
  return false;
22
+ }
18
23
  const resources = loadLocales(localesDir);
19
- if (Object.keys(resources).length === 0)
24
+ if (Object.keys(resources).length === 0) {
25
+ if (existsSync(out))
26
+ rmSync(out, { force: true });
20
27
  return false;
28
+ }
21
29
  mkdirSync(dirname(out), { recursive: true });
22
30
  // 결정 352: 기준 로케일 = config i18n.fallbackLng(호출자가 정적 분석으로 전달).
23
31
  // 이전엔 항상 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에
@@ -18,12 +18,22 @@ RUN pnpm install --frozen-lockfile
18
18
  FROM base AS build
19
19
  COPY --from=deps /app/node_modules ./node_modules
20
20
  COPY . .
21
+ # 결정 408: 브라우저에 실리는 공개 변수(VITE_*)는 **빌드 시점에 번들로 각인**된다 —
22
+ # 런타임 env(compose.prod.yaml)로는 못 바꾼다. 값이 필요하면 빌드 인자로 넘긴다:
23
+ # docker build --build-arg VITE_API_BASE=https://api.example.com .
24
+ # 넘기지 않으면 아래 .env.example 의 placeholder 가 그대로 각인되므로, 공개 변수를
25
+ # 쓰는 앱은 반드시 build-arg 로 주거나 .env.example 에 **공개 가능한 실값**을 둔다.
26
+ # (비밀은 VITE_ 로 만들지 말 것 — 번들은 누구나 읽는다.)
27
+ ARG VITE_VARS=""
21
28
  # gaon build 는 .gaon/env.d.ts 생성에 .env 가 필수(결정 198)인데 .env 는 이미지에
22
29
  # 넣지 않는다(.dockerignore) — .env.example 을 임시 복제해 빌드하고 즉시 지운다.
23
30
  # 지우는 이유: example 의 placeholder(SESSION_SECRET 등)가 런타임에 남으면 compose
24
31
  # 가 env 를 안 넘겼을 때 fail-loud 검증을 조용히 통과시킨다(결정 313). 런타임 env
25
32
  # 는 compose.prod.yaml 의 environment 가 단일 소스다.
26
- RUN cp .env.example .env && pnpm build && rm -f .env
33
+ RUN cp .env.example .env \
34
+ && for kv in $VITE_VARS; do printf '%s\n' "$kv" >> .env; done \
35
+ && pnpm build \
36
+ && rm -f .env
27
37
 
28
38
  # 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
29
39
  FROM base AS runtime
@@ -191,7 +191,10 @@ await OrderPlaced.emit({ orderId: 1n })
191
191
  - 실패하면 백오프(잡과 같은 곡선 `[1s, 5s, 30s, 5m, 1h]`)로 재전달되고, 최대
192
192
  재전달(기본 6 · 최초 포함) 소진 시 **영구 폐기**된다. 즉 계속 실패하는 이벤트는
193
193
  약 **1시간 36분** 뒤 사라진다 — `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
194
- (결정 308 · 이전엔 human 모드 무신호). 놓치면 되는 처리는 리스너에서 잡을
194
+ (결정 308 · 이전엔 human 모드 무신호). **크래시 루프**(핸들러 throw 아니라
195
+ 프로세스가 ack 전에 반복 사망)로 소진돼도 MAX_DELIVERIES advisory 백스톱이
196
+ 같은 `✗ 이벤트 폐기` 신호를 낸다(결정 398 · 잡의 결정 347 동형 · advisory 는
197
+ 비영속이라 best-effort). 놓치면 안 되는 처리는 리스너에서 잡을
195
198
  발행해(`.later()`) 잡의 재시도·DLQ 배터리로 넘긴다.
196
199
  - **리스너별 순차 처리는 보장되지 않는다** — 재시도 대기 중 다음 이벤트가 먼저
197
200
  처리될 수 있고, 드물게 같은 리스너의 두 이벤트가 겹칠 수 있다. 순서·중복에
@@ -235,15 +238,20 @@ export const PlaceOrder = service(async (input: { name: string }) => {
235
238
  어떤 경로로도 삭제되지 않으므로 유실이 없다.
236
239
  - at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
237
240
  흡수한다(claim 리스 60s < dedupe 창 120s 라 "발행 후 표시 전 크래시"
238
- 재발행도 창 안에서 접힌다).
241
+ 재발행도 창 안에서 접힌다). **"claim 리스 < dedupe 창" 은 부팅이 강제한다
242
+ (결정 396)** — `dedupeWindowMs` 를 claim 아래로 줄이는 오설정은 조용한 이중
243
+ 배달이 되므로 `gaon work` 가 수리 안내와 함께 fail-loud 한다. claim 리스는
244
+ env `GAON_OUTBOX_CLAIM_TIMEOUT_MS`(또는 `runWork` 의 `outboxClaimTimeoutMs`)
245
+ 로 조정한다.
239
246
  - 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
240
247
  기동 시 보장된다(결정 144 · nats 설정이 있을 때).
241
248
  - 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
242
- (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격·폴링 주기는 env
243
- `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`
249
+ (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격·폴링 주기·claim 리스는 env
250
+ `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS
251
+ `GAON_OUTBOX_CLAIM_TIMEOUT_MS`(결정 396)
244
252
  로 조정한다(결정 312 · `GAON_WORKER_*` 와 대칭 · `gaon work` 가 읽는다). 프로그래매틱
245
- 경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs`.
246
- 미발행 행은 절대 삭제되지 않는다.
253
+ 경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs
254
+ `outboxClaimTimeoutMs`. 미발행 행은 절대 삭제되지 않는다.
247
255
  - **발행 실패는 행 단위로 격리된다(결정 306 · 346).** 한 행의 publish 가 실패해도
248
256
  (예: 페이로드가 NATS `max_payload` 1MiB 초과) 그 행만 claim 된 채 남아 리스
249
257
  만료(60s) 후 재시도되고, 뒤 행들은 정상 발행된다 — 한 행이 아웃박스 전체를
@@ -301,7 +309,8 @@ export default schedule((s) => {
301
309
  워커에 로드밸런싱된다. 발행은 1인, 처리는 N인.
302
310
  - **exactly-once(발행 기준)** — 한 스케줄 틱은 리더 1인이 한 번만 발행한다.
303
311
  리더 교체(페일오버) 순간 구·신 리더가 같은 틱을 겹쳐 발행해도, 결정론적
304
- dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다(결정 233).
312
+ dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다 — cron 은 발화
313
+ 분(minute) 키(결정 233), **every 는 위상 슬롯 키**(결정 394 · 아래)로 접힌다.
305
314
  잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
306
315
  처리돼도 안전하게).
307
316
  - **`s.every` 위상은 리더 교체를 가로질러 보존된다(결정 349).** 마지막 발화
@@ -309,6 +318,13 @@ export default schedule((s) => {
309
318
  아니면 잔여 시간만 기다린다 — 재선출마다 타이머가 리셋돼 리스 플래핑이
310
319
  interval 보다 잦으면 every 잡이 영영 안 돌던 기아가 없다. 리스 갱신도 순단
311
320
  1~2회는 재시도 후에만 리더를 내려놓는다(불필요한 failover·리셋 억제).
321
+ - **`s.every` 의 failover 이중발화도 접힌다(결정 394).** 구 리더가 발화 직후
322
+ 위상 기록(KV put · best-effort)을 못 남기고 죽으면 신 리더가 같은 슬롯을
323
+ 즉시 재발화하는데, every 발화가 위상 슬롯 기반 결정적 dedupe 키
324
+ (`enqueueScheduled`)를 쓰므로 같은 슬롯의 재발화는 JetStream 중복 윈도우
325
+ 안에서 1건으로 수렴한다 — 결정 349 이후 every 만 랜덤 id(`later()`)라
326
+ 안 접히던 창을 닫았다. 사임(stop) 시 리스 키 삭제도 CAS 라 stale 리더의
327
+ 종료가 신 리더의 키를 지우지 않는다(결정 395).
312
328
  - **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
313
329
  릴레이는 **`gaon work` 전용**이다. 웹 프로세스는 잡을 **발행**만 할 수 있고
314
330
  (`.later()`), 처리·스케줄은 워커가 한다. **운영에서** 스케줄이 안 도는 흔한
@@ -352,6 +368,9 @@ drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤
352
368
  - **`gaon work`** — 신규 잡 pull 을 멈추고 **진행 중 잡을 완료**한 뒤 종료한다
353
369
  (`drainTimeoutMs` 상한 · 기본 30s). 스케줄러 리더는 즉시 반납해 다른 인스턴스가
354
370
  승계한다. drain 상한을 넘긴 잡은 ack 되지 않아 재전달(크래시 복구)된다.
371
+ 상한은 **큐가 동시성 포화 상태로 긴 잡을 물고 있어도** 지켜진다(결정 397 —
372
+ 종전엔 이 경우 소비 루프 종료 대기가 상한 밖이라 stop 이 잡 완료까지 무기한
373
+ 붙들렸다).
355
374
  - **컨테이너 기본 워커 1** — `gaon serve` 클러스터(`--workers`)도 SIGTERM 에
356
375
  워커들을 graceful drain 후 종료한다(결정 84).
357
376
 
@@ -463,6 +482,10 @@ async create() {
463
482
  발행은 `afterCommit()` 또는 아웃박스로.
464
483
  - **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
465
484
  (`agents/testing.md`).
485
+ - **MySQL 은 8.0+(`explicit_defaults_for_timestamp=ON`) 전제** (결정 400) —
486
+ 구식 설정(OFF · 5.7 기본)에서는 아웃박스 테이블의 timestamp 컬럼이 NOT NULL
487
+ + zero-date 기본으로 생성돼 strict `NO_ZERO_DATE` 와 충돌할 수 있다. 관리형
488
+ MySQL 에서 해당 플래그가 OFF 면 ON 으로 바꿔라.
466
489
  - **동시 실행 방지에 로컬 뮤텍스·플래그 금지** (결정 147) — `let running = false`
467
490
  같은 프로세스 로컬 가드는 멀티 인스턴스에서 안 먹는다. `lock(key, fn)` 을
468
491
  쓴다. 운영에서 `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한
@@ -493,4 +516,10 @@ async create() {
493
516
  | 결정 348 | 워커 큐별 동시성 게이트(§1) — 전역 inflight 비교가 낳던 교차 큐 간섭 제거(선언 `concurrency` = 실제 동시 처리) |
494
517
  | 결정 349 | 리스 갱신 순단 재시도 + every 위상 KV 보존(§5) — 키가 내 것이면 revision 동기화 재시도 후에만 revoke · `gaon_scheduler` KV 로 위상 이어받기(플래핑 기아 봉합) |
495
518
  | 결정 351 | DLQ 조회 배치 스캔(§1) — ordered 컨슈머 fetch 로 삭제 갭 서버 스킵 · findDlq 1000건 상한 제거(옛 레코드 retry 복원) |
519
+ | 결정 394 | every failover 이중발화 dedupe(§5) — 위상 슬롯 기반 결정적 키(`enqueueScheduled`)로 구·신 리더의 같은 슬롯 재발화가 1건으로 수렴(결정 349 가 연 창 봉합 · cron 결정 233 동형) |
520
+ | 결정 395 | 리스 사임 CAS 삭제(§5) — stop() 이 `previousSeq` 로 자기 revision 에서만 키 삭제 · stale 리더 종료가 신 리더 키를 지우던 재선출 순단 봉합(허브 endpoint 결정 259 동형) |
521
+ | 결정 396 | 아웃박스 claim<dedupe 불변식 부팅 강제(§4) — 위반 시 fail-loud(조용한 이중 배달 봉합) · claim 리스 운영 표면 `GAON_OUTBOX_CLAIM_TIMEOUT_MS`/`outboxClaimTimeoutMs` 신설 |
522
+ | 결정 397 | graceful drain 상한 유계화(§6) — 포화 큐의 긴 잡이 소비 루프 종료를 붙들어 `drainTimeoutMs` 가 무효이던 우회 봉합(closers 도 같은 deadline 공유 · 워커·리스너 공통) |
523
+ | 결정 398 | 리스너 크래시 루프 소진 신호(§3) — EVENTS 스트림 MAX_DELIVERIES advisory 백스톱으로 `dropped` 신호(잡 결정 347 동형 · 공유 스트림이라 메시지 삭제는 안 함) |
524
+ | 결정 400 | P3 청소 묶음 — 허브 KV 복원 오염 키 방어(try/continue) · 라인 디코더 완결 초과 라인도 onOverflow(무신호 명령 소실 금지) · MySQL timestamp 모드 함정 문서화 · listDlq 롤링 버퍼(O(limit) 메모리) · advisory dead 는 삭제 성공 워커만 신호 + 삭제 실패 잔류 관측 |
496
525
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |