@mandujs/core 0.12.2 → 0.13.1

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.
@@ -1,46 +1,46 @@
1
- import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
2
- import { verifyLock, computeHash } from "../spec/lock";
3
- import { runContractGuardCheck } from "./contract-guard";
4
- import { validateSlotContent } from "../slot/validator";
5
- import type { RoutesManifest } from "../spec/schema";
6
- import type { GeneratedMap } from "../generator/generate";
7
- import { loadManduConfig, type GuardRuleSeverity } from "../config";
8
- import path from "path";
9
- import fs from "fs/promises";
10
-
11
- export interface GuardCheckResult {
12
- passed: boolean;
13
- violations: GuardViolation[];
14
- }
15
-
16
- function normalizeSeverity(level: GuardRuleSeverity): "error" | "warning" | "off" {
17
- if (level === "warn") return "warning";
18
- return level;
19
- }
20
-
21
- function applyRuleSeverity(
22
- violations: GuardViolation[],
23
- config: { rules?: Record<string, GuardRuleSeverity>; contractRequired?: GuardRuleSeverity }
24
- ): GuardViolation[] {
25
- const resolved: GuardViolation[] = [];
26
- const ruleOverrides = config.rules ?? {};
27
-
28
- for (const violation of violations) {
29
- let override = ruleOverrides[violation.ruleId];
30
- if (violation.ruleId === "CONTRACT_MISSING" && config.contractRequired) {
31
- override = config.contractRequired;
32
- }
33
-
34
- const baseSeverity = violation.severity ?? GUARD_RULES[violation.ruleId]?.severity ?? "error";
35
- const finalSeverity = override ? normalizeSeverity(override) : baseSeverity;
36
-
37
- if (finalSeverity === "off") continue;
38
-
39
- resolved.push({ ...violation, severity: finalSeverity });
40
- }
41
-
42
- return resolved;
43
- }
1
+ import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
2
+ import { verifyLock, computeHash } from "../spec/lock";
3
+ import { runContractGuardCheck } from "./contract-guard";
4
+ import { validateSlotContent } from "../slot/validator";
5
+ import type { RoutesManifest } from "../spec/schema";
6
+ import type { GeneratedMap } from "../generator/generate";
7
+ import { loadManduConfig, type GuardRuleSeverity } from "../config";
8
+ import path from "path";
9
+ import fs from "fs/promises";
10
+
11
+ export interface GuardCheckResult {
12
+ passed: boolean;
13
+ violations: GuardViolation[];
14
+ }
15
+
16
+ function normalizeSeverity(level: GuardRuleSeverity): "error" | "warning" | "off" {
17
+ if (level === "warn") return "warning";
18
+ return level;
19
+ }
20
+
21
+ function applyRuleSeverity(
22
+ violations: GuardViolation[],
23
+ config: { rules?: Record<string, GuardRuleSeverity>; contractRequired?: GuardRuleSeverity }
24
+ ): GuardViolation[] {
25
+ const resolved: GuardViolation[] = [];
26
+ const ruleOverrides = config.rules ?? {};
27
+
28
+ for (const violation of violations) {
29
+ let override = ruleOverrides[violation.ruleId];
30
+ if (violation.ruleId === "CONTRACT_MISSING" && config.contractRequired) {
31
+ override = config.contractRequired;
32
+ }
33
+
34
+ const baseSeverity = violation.severity ?? GUARD_RULES[violation.ruleId]?.severity ?? "error";
35
+ const finalSeverity = override ? normalizeSeverity(override) : baseSeverity;
36
+
37
+ if (finalSeverity === "off") continue;
38
+
39
+ resolved.push({ ...violation, severity: finalSeverity });
40
+ }
41
+
42
+ return resolved;
43
+ }
44
44
 
45
45
  async function fileExists(filePath: string): Promise<boolean> {
46
46
  try {
@@ -116,8 +116,8 @@ export async function checkInvalidGeneratedImport(
116
116
  // Scan non-generated source files
117
117
  const sourceDirs = [
118
118
  path.join(rootDir, "packages"),
119
- path.join(rootDir, "apps/server"),
120
- path.join(rootDir, "apps/web"),
119
+ path.join(rootDir, "src"),
120
+ path.join(rootDir, "app"),
121
121
  ];
122
122
 
123
123
  for (const sourceDir of sourceDirs) {
@@ -200,15 +200,15 @@ export async function checkSlotContentValidation(
200
200
  if (issue.severity === "error") {
201
201
  // Map slot issue codes to guard rule IDs
202
202
  let ruleId = "SLOT_VALIDATION_ERROR";
203
- if (issue.code === "MISSING_DEFAULT_EXPORT") {
204
- ruleId = GUARD_RULES.SLOT_MISSING_DEFAULT_EXPORT?.id ?? "SLOT_MISSING_DEFAULT_EXPORT";
205
- } else if (issue.code === "NO_RESPONSE_PATTERN" || issue.code === "INVALID_HANDLER_RETURN") {
206
- ruleId = GUARD_RULES.SLOT_INVALID_RETURN?.id ?? "SLOT_INVALID_RETURN";
207
- } else if (issue.code === "MISSING_FILLING_PATTERN") {
208
- ruleId = GUARD_RULES.SLOT_MISSING_FILLING_PATTERN?.id ?? "SLOT_MISSING_FILLING_PATTERN";
209
- } else if (issue.code === "ZOD_DIRECT_IMPORT") {
210
- ruleId = GUARD_RULES.SLOT_ZOD_DIRECT_IMPORT?.id ?? "SLOT_ZOD_DIRECT_IMPORT";
211
- }
203
+ if (issue.code === "MISSING_DEFAULT_EXPORT") {
204
+ ruleId = GUARD_RULES.SLOT_MISSING_DEFAULT_EXPORT?.id ?? "SLOT_MISSING_DEFAULT_EXPORT";
205
+ } else if (issue.code === "NO_RESPONSE_PATTERN" || issue.code === "INVALID_HANDLER_RETURN") {
206
+ ruleId = GUARD_RULES.SLOT_INVALID_RETURN?.id ?? "SLOT_INVALID_RETURN";
207
+ } else if (issue.code === "MISSING_FILLING_PATTERN") {
208
+ ruleId = GUARD_RULES.SLOT_MISSING_FILLING_PATTERN?.id ?? "SLOT_MISSING_FILLING_PATTERN";
209
+ } else if (issue.code === "ZOD_DIRECT_IMPORT") {
210
+ ruleId = GUARD_RULES.SLOT_ZOD_DIRECT_IMPORT?.id ?? "SLOT_ZOD_DIRECT_IMPORT";
211
+ }
212
212
 
213
213
  violations.push({
214
214
  ruleId,
@@ -339,7 +339,7 @@ export async function checkSpecDirNaming(
339
339
  ruleId: GUARD_RULES.SLOT_DIR_INVALID_FILE.id,
340
340
  file: `spec/slots/${file}`,
341
341
  message: `spec/slots/에 .slot.ts가 아닌 파일: ${file}`,
342
- suggestion: `.slot.ts로 이름을 바꾸거나, 이 파일이 client slot이면 apps/web/components/로 이동하세요`,
342
+ suggestion: `.slot.ts로 이름을 바꾸거나, 이 파일이 client slot이면 spec/slots/로 .client.ts 접미사로 이동하세요`,
343
343
  });
344
344
  }
345
345
  }
@@ -364,76 +364,76 @@ export async function checkSpecDirNaming(
364
364
  return violations;
365
365
  }
366
366
 
367
- export async function runGuardCheck(
368
- manifest: RoutesManifest,
369
- rootDir: string
370
- ): Promise<GuardCheckResult> {
371
- const config = await loadManduConfig(rootDir);
372
-
373
- const lockPath = path.join(rootDir, "spec/spec.lock.json");
374
- const mapPath = path.join(rootDir, "packages/core/map/generated.map.json");
375
-
376
- // ============================================
377
- // Phase 1: 독립적인 검사 병렬 실행
378
- // ============================================
379
- const [
380
- hashViolation,
381
- importViolations,
382
- slotViolations,
383
- specDirViolations,
384
- islandViolations,
385
- ] = await Promise.all([
386
- checkSpecHashMismatch(manifest, lockPath),
387
- checkInvalidGeneratedImport(rootDir),
388
- checkSlotFileExists(manifest, rootDir),
389
- checkSpecDirNaming(rootDir),
390
- checkIslandFirstIntegrity(manifest, rootDir),
391
- ]);
392
-
393
- const violations: GuardViolation[] = [];
394
- if (hashViolation) violations.push(hashViolation);
395
- violations.push(...importViolations);
396
- violations.push(...slotViolations);
397
- violations.push(...specDirViolations);
398
- violations.push(...islandViolations);
399
-
400
- // ============================================
401
- // Phase 2: generatedMap 의존 검사
402
- // ============================================
403
- let generatedMap: GeneratedMap | null = null;
404
- if (await fileExists(mapPath)) {
405
- try {
406
- const mapContent = await Bun.file(mapPath).text();
407
- generatedMap = JSON.parse(mapContent);
408
- } catch {
409
- // Map file corrupted or missing
410
- }
411
- }
412
-
413
- if (generatedMap) {
414
- const [editViolations, forbiddenViolations] = await Promise.all([
415
- checkGeneratedManualEdit(rootDir, generatedMap),
416
- checkForbiddenImportsInGenerated(rootDir, generatedMap),
417
- ]);
418
- violations.push(...editViolations);
419
- violations.push(...forbiddenViolations);
420
- }
421
-
422
- // ============================================
423
- // Phase 3: Slot + Contract 검사 병렬
424
- // ============================================
425
- const [slotContentViolations, contractViolations] = await Promise.all([
426
- checkSlotContentValidation(manifest, rootDir),
427
- runContractGuardCheck(manifest, rootDir),
428
- ]);
429
- violations.push(...slotContentViolations);
430
- violations.push(...contractViolations);
431
-
432
- const resolvedViolations = applyRuleSeverity(violations, config.guard ?? {});
433
- const passed = resolvedViolations.every((v) => v.severity !== "error");
434
-
435
- return {
436
- passed,
437
- violations: resolvedViolations,
438
- };
439
- }
367
+ export async function runGuardCheck(
368
+ manifest: RoutesManifest,
369
+ rootDir: string
370
+ ): Promise<GuardCheckResult> {
371
+ const config = await loadManduConfig(rootDir);
372
+
373
+ const lockPath = path.join(rootDir, ".mandu/spec.lock.json");
374
+ const mapPath = path.join(rootDir, ".mandu/generated/generated.map.json");
375
+
376
+ // ============================================
377
+ // Phase 1: 독립적인 검사 병렬 실행
378
+ // ============================================
379
+ const [
380
+ hashViolation,
381
+ importViolations,
382
+ slotViolations,
383
+ specDirViolations,
384
+ islandViolations,
385
+ ] = await Promise.all([
386
+ checkSpecHashMismatch(manifest, lockPath),
387
+ checkInvalidGeneratedImport(rootDir),
388
+ checkSlotFileExists(manifest, rootDir),
389
+ checkSpecDirNaming(rootDir),
390
+ checkIslandFirstIntegrity(manifest, rootDir),
391
+ ]);
392
+
393
+ const violations: GuardViolation[] = [];
394
+ if (hashViolation) violations.push(hashViolation);
395
+ violations.push(...importViolations);
396
+ violations.push(...slotViolations);
397
+ violations.push(...specDirViolations);
398
+ violations.push(...islandViolations);
399
+
400
+ // ============================================
401
+ // Phase 2: generatedMap 의존 검사
402
+ // ============================================
403
+ let generatedMap: GeneratedMap | null = null;
404
+ if (await fileExists(mapPath)) {
405
+ try {
406
+ const mapContent = await Bun.file(mapPath).text();
407
+ generatedMap = JSON.parse(mapContent);
408
+ } catch {
409
+ // Map file corrupted or missing
410
+ }
411
+ }
412
+
413
+ if (generatedMap) {
414
+ const [editViolations, forbiddenViolations] = await Promise.all([
415
+ checkGeneratedManualEdit(rootDir, generatedMap),
416
+ checkForbiddenImportsInGenerated(rootDir, generatedMap),
417
+ ]);
418
+ violations.push(...editViolations);
419
+ violations.push(...forbiddenViolations);
420
+ }
421
+
422
+ // ============================================
423
+ // Phase 3: Slot + Contract 검사 병렬
424
+ // ============================================
425
+ const [slotContentViolations, contractViolations] = await Promise.all([
426
+ checkSlotContentValidation(manifest, rootDir),
427
+ runContractGuardCheck(manifest, rootDir),
428
+ ]);
429
+ violations.push(...slotContentViolations);
430
+ violations.push(...contractViolations);
431
+
432
+ const resolvedViolations = applyRuleSeverity(violations, config.guard ?? {});
433
+ const passed = resolvedViolations.every((v) => v.severity !== "error");
434
+
435
+ return {
436
+ passed,
437
+ violations: resolvedViolations,
438
+ };
439
+ }
@@ -52,20 +52,41 @@ describe("CQRS Preset - Structure", () => {
52
52
  // ═══════════════════════════════════════════════════════════════════════════
53
53
 
54
54
  describe("CQRS Preset - Command/Query Separation", () => {
55
- // TODO(human): CQRS 분리 규칙 검증 테스트를 작성하세요.
56
- //
57
- // 힌트:
58
- // - cqrsPreset.layers 배열에서 특정 레이어를 찾으려면:
59
- // const commandsLayer = cqrsPreset.layers.find(l => l.name === "application/commands");
60
- // - commandsLayer.canImport 배열에 특정 레이어가 포함/미포함인지 검증
61
- //
62
- // 작성해야 테스트 케이스:
63
- // 1. commands는 queries import할 수 없어야 함
64
- // 2. queries는 commands와 events를 import할 수 없어야 함
65
- // 3. commands는 domain, dto, events를 import할 수 있어야 함
66
- // 4. queries는 domain, dto를 import할 수 있어야 함
67
- // 5. domain은 shared만 import할 수 있어야 함
68
- // 6. shared는 아무것도 import 없어야
55
+ const findLayer = (name: string) => cqrsPreset.layers.find((l) => l.name === name)!;
56
+
57
+ it("commands cannot import queries", () => {
58
+ const commands = findLayer("application/commands");
59
+ expect(commands.canImport).not.toContain("application/queries");
60
+ });
61
+
62
+ it("queries cannot import commands or events", () => {
63
+ const queries = findLayer("application/queries");
64
+ expect(queries.canImport).not.toContain("application/commands");
65
+ expect(queries.canImport).not.toContain("application/events");
66
+ });
67
+
68
+ it("commands can import domain, dto, events", () => {
69
+ const commands = findLayer("application/commands");
70
+ expect(commands.canImport).toContain("domain");
71
+ expect(commands.canImport).toContain("application/dto");
72
+ expect(commands.canImport).toContain("application/events");
73
+ });
74
+
75
+ it("queries can import domain, dto", () => {
76
+ const queries = findLayer("application/queries");
77
+ expect(queries.canImport).toContain("domain");
78
+ expect(queries.canImport).toContain("application/dto");
79
+ });
80
+
81
+ it("domain can only import shared", () => {
82
+ const domain = findLayer("domain");
83
+ expect(domain.canImport).toEqual(["shared"]);
84
+ });
85
+
86
+ it("shared cannot import anything", () => {
87
+ const shared = findLayer("shared");
88
+ expect(shared.canImport).toEqual([]);
89
+ });
69
90
  });
70
91
 
71
92
  // ═══════════════════════════════════════════════════════════════════════════
package/src/index.ts CHANGED
@@ -21,9 +21,10 @@ export * from "./seo";
21
21
  export * from "./island";
22
22
  export * from "./intent";
23
23
  export * from "./devtools";
24
+ export * from "./paths";
24
25
 
25
26
  // Consolidated Mandu namespace
26
- import { ManduFilling, ManduContext, ManduFillingFactory } from "./filling";
27
+ import { ManduFilling, ManduContext, ManduFillingFactory, createSSEConnection } from "./filling";
27
28
  import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract } from "./contract";
28
29
  import { defineContract, generateAllFromContract, generateOpenAPISpec } from "./contract/define";
29
30
  import { island, isIsland, type IslandComponent, type HydrationStrategy } from "./island";
@@ -74,6 +75,11 @@ export const Mandu = {
74
75
  */
75
76
  context: ManduFillingFactory.context,
76
77
 
78
+ /**
79
+ * Create a Server-Sent Events (SSE) connection helper
80
+ */
81
+ sse: createSSEConnection,
82
+
77
83
  // === Contract API ===
78
84
  /**
79
85
  * Define a typed API contract
@@ -0,0 +1,47 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolveGeneratedPaths, GENERATED_RELATIVE_PATHS } from "./paths";
3
+ import path from "path";
4
+
5
+ describe("resolveGeneratedPaths", () => {
6
+ test("should return .mandu/generated based paths", () => {
7
+ const rootDir = "/project";
8
+ const paths = resolveGeneratedPaths(rootDir);
9
+
10
+ expect(paths.serverRoutesDir).toBe(
11
+ path.join("/project", ".mandu/generated/server/routes")
12
+ );
13
+ expect(paths.webRoutesDir).toBe(
14
+ path.join("/project", ".mandu/generated/web/routes")
15
+ );
16
+ expect(paths.typesDir).toBe(
17
+ path.join("/project", ".mandu/generated/server/types")
18
+ );
19
+ expect(paths.mapDir).toBe(
20
+ path.join("/project", ".mandu/generated")
21
+ );
22
+ });
23
+
24
+ test("should handle Windows-style root paths", () => {
25
+ const rootDir = "C:\\Users\\User\\project";
26
+ const paths = resolveGeneratedPaths(rootDir);
27
+
28
+ expect(paths.serverRoutesDir).toContain(".mandu");
29
+ expect(paths.serverRoutesDir).toContain("server");
30
+ expect(paths.serverRoutesDir).toContain("routes");
31
+ });
32
+ });
33
+
34
+ describe("GENERATED_RELATIVE_PATHS", () => {
35
+ test("should not contain apps/ prefix", () => {
36
+ expect(GENERATED_RELATIVE_PATHS.serverRoutes).not.toContain("apps/");
37
+ expect(GENERATED_RELATIVE_PATHS.webRoutes).not.toContain("apps/");
38
+ expect(GENERATED_RELATIVE_PATHS.types).not.toContain("apps/");
39
+ });
40
+
41
+ test("should use .mandu/generated prefix", () => {
42
+ expect(GENERATED_RELATIVE_PATHS.serverRoutes).toStartWith(".mandu/generated");
43
+ expect(GENERATED_RELATIVE_PATHS.webRoutes).toStartWith(".mandu/generated");
44
+ expect(GENERATED_RELATIVE_PATHS.types).toStartWith(".mandu/generated");
45
+ expect(GENERATED_RELATIVE_PATHS.map).toStartWith(".mandu/generated");
46
+ });
47
+ });
package/src/paths.ts ADDED
@@ -0,0 +1,47 @@
1
+ import path from "path";
2
+
3
+ /**
4
+ * 프레임워크가 생성하는 파일들의 경로 구조
5
+ * apps/ 하드코딩 대신 .mandu/ 기반 중앙 관리
6
+ */
7
+ export interface GeneratedPaths {
8
+ /** 서버 라우트 핸들러 디렉토리 */
9
+ serverRoutesDir: string;
10
+ /** 웹 라우트 컴포넌트 디렉토리 */
11
+ webRoutesDir: string;
12
+ /** 타입 글루 디렉토리 */
13
+ typesDir: string;
14
+ /** 생성 맵 디렉토리 */
15
+ mapDir: string;
16
+ /** 생성된 매니페스트 경로 */
17
+ manifestPath: string;
18
+ /** 생성된 lock 경로 */
19
+ lockPath: string;
20
+ }
21
+
22
+ /**
23
+ * 프로젝트 루트에서 생성 경로를 결정
24
+ */
25
+ export function resolveGeneratedPaths(rootDir: string): GeneratedPaths {
26
+ return {
27
+ serverRoutesDir: path.join(rootDir, ".mandu/generated/server/routes"),
28
+ webRoutesDir: path.join(rootDir, ".mandu/generated/web/routes"),
29
+ typesDir: path.join(rootDir, ".mandu/generated/server/types"),
30
+ mapDir: path.join(rootDir, ".mandu/generated"),
31
+ manifestPath: path.join(rootDir, ".mandu/routes.manifest.json"),
32
+ lockPath: path.join(rootDir, ".mandu/spec.lock.json"),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * 생성된 파일의 상대 경로 (generatedMap.files 키 등에 사용)
38
+ */
39
+ export const GENERATED_RELATIVE_PATHS = {
40
+ serverRoutes: ".mandu/generated/server/routes",
41
+ webRoutes: ".mandu/generated/web/routes",
42
+ types: ".mandu/generated/server/types",
43
+ map: ".mandu/generated",
44
+ manifest: ".mandu/routes.manifest.json",
45
+ lock: ".mandu/spec.lock.json",
46
+ history: ".mandu/history",
47
+ } as const;
@@ -32,7 +32,7 @@ export function buildGuardReport(checkResult: GuardCheckResult): ManuduReport {
32
32
  );
33
33
 
34
34
  if (hasHashMismatch) {
35
- nextActions.push("bunx mandu spec-upsert --file spec/routes.manifest.json");
35
+ nextActions.push("bunx mandu routes generate");
36
36
  }
37
37
  if (hasManualEdit) {
38
38
  nextActions.push("bunx mandu generate");