sonamu 0.8.13 → 0.8.14

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.
Files changed (96) hide show
  1. package/dist/api/sonamu.d.ts.map +1 -1
  2. package/dist/api/sonamu.js +2 -3
  3. package/dist/auth/auth-generator.d.ts +8 -0
  4. package/dist/auth/auth-generator.d.ts.map +1 -1
  5. package/dist/auth/auth-generator.js +33 -1
  6. package/dist/auth/better-auth-entities.d.ts.map +1 -1
  7. package/dist/auth/better-auth-entities.js +12 -2
  8. package/dist/bin/cli.js +18 -3
  9. package/dist/cone/cone-generator.js +10 -4
  10. package/dist/database/knex.d.ts.map +1 -1
  11. package/dist/database/knex.js +64 -2
  12. package/dist/database/puri.d.ts +9 -1
  13. package/dist/database/puri.d.ts.map +1 -1
  14. package/dist/database/puri.js +42 -1
  15. package/dist/database/puri.types.d.ts +2 -0
  16. package/dist/database/puri.types.d.ts.map +1 -1
  17. package/dist/database/puri.types.js +6 -2
  18. package/dist/entity/entity-manager.d.ts +149 -1
  19. package/dist/entity/entity-manager.d.ts.map +1 -1
  20. package/dist/entity/entity-manager.js +68 -4
  21. package/dist/migration/__tests__/code-generation.search-text.test.js +435 -0
  22. package/dist/migration/code-generation.d.ts.map +1 -1
  23. package/dist/migration/code-generation.js +696 -32
  24. package/dist/migration/migration-set.js +3 -1
  25. package/dist/migration/postgresql-schema-reader.d.ts +16 -2
  26. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  27. package/dist/migration/postgresql-schema-reader.js +281 -7
  28. package/dist/stream/sse.js +5 -3
  29. package/dist/template/__tests__/generated.template.search-text.test.js +99 -0
  30. package/dist/template/generated.template.test-d.js +24 -0
  31. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  32. package/dist/template/implementations/generated.template.js +2 -2
  33. package/dist/template/implementations/init_types.template.d.ts.map +1 -1
  34. package/dist/template/implementations/init_types.template.js +11 -3
  35. package/dist/template/zod-converter.d.ts.map +1 -1
  36. package/dist/template/zod-converter.js +6 -2
  37. package/dist/testing/dev-test-routes.d.ts.map +1 -1
  38. package/dist/testing/dev-test-routes.js +5 -3
  39. package/dist/testing/fixture-generator.d.ts +13 -0
  40. package/dist/testing/fixture-generator.d.ts.map +1 -1
  41. package/dist/testing/fixture-generator.js +105 -8
  42. package/dist/testing/fixture-manager.d.ts.map +1 -1
  43. package/dist/testing/fixture-manager.js +19 -2
  44. package/dist/types/__tests__/entity-json-schema-search-text.test.js +256 -0
  45. package/dist/types/types.d.ts +494 -1
  46. package/dist/types/types.d.ts.map +1 -1
  47. package/dist/types/types.js +117 -13
  48. package/dist/ui/api.d.ts.map +1 -1
  49. package/dist/ui/api.js +14 -2
  50. package/dist/ui/cdd-service.d.ts +16 -14
  51. package/dist/ui/cdd-service.d.ts.map +1 -1
  52. package/dist/ui/cdd-service.js +145 -37
  53. package/dist/ui/cdd-types.d.ts +60 -0
  54. package/dist/ui/cdd-types.d.ts.map +1 -0
  55. package/dist/ui/cdd-types.js +3 -0
  56. package/dist/ui-web/assets/index-D4XFBV-f.css +1 -0
  57. package/dist/ui-web/assets/{index-CQ_S40bD.js → index-D_19-Pi4.js} +87 -87
  58. package/dist/ui-web/index.html +2 -2
  59. package/package.json +7 -3
  60. package/src/api/sonamu.ts +1 -2
  61. package/src/auth/auth-generator.ts +38 -0
  62. package/src/auth/better-auth-entities.ts +18 -1
  63. package/src/bin/cli.ts +15 -1
  64. package/src/cone/cone-generator.ts +9 -3
  65. package/src/database/knex.ts +62 -4
  66. package/src/database/puri.ts +71 -0
  67. package/src/database/puri.types.ts +2 -0
  68. package/src/entity/entity-manager.ts +95 -3
  69. package/src/migration/__tests__/code-generation.search-text.test.ts +390 -0
  70. package/src/migration/code-generation.ts +848 -34
  71. package/src/migration/migration-set.ts +2 -0
  72. package/src/migration/postgresql-schema-reader.ts +366 -9
  73. package/src/skills/sonamu/auth-migration.md +80 -0
  74. package/src/skills/sonamu/cdd.md +148 -28
  75. package/src/skills/sonamu/cone.md +16 -0
  76. package/src/skills/sonamu/entity-relations.md +1 -1
  77. package/src/skills/sonamu/fixture-cli.md +4 -0
  78. package/src/skills/sonamu/frontend.md +65 -0
  79. package/src/skills/sonamu/migration.md +3 -1
  80. package/src/skills/sonamu/model.md +28 -0
  81. package/src/skills/sonamu/workflow.md +12 -5
  82. package/src/stream/sse.ts +4 -2
  83. package/src/template/__tests__/generated.template.search-text.test.ts +89 -0
  84. package/src/template/generated.template.test-d.ts +46 -0
  85. package/src/template/implementations/generated.template.ts +4 -1
  86. package/src/template/implementations/init_types.template.ts +20 -5
  87. package/src/template/zod-converter.ts +5 -0
  88. package/src/testing/dev-test-routes.ts +4 -2
  89. package/src/testing/fixture-generator.ts +157 -9
  90. package/src/testing/fixture-manager.ts +15 -1
  91. package/src/types/__tests__/entity-json-schema-search-text.test.ts +179 -0
  92. package/src/types/types.ts +168 -12
  93. package/src/ui/api.ts +24 -1
  94. package/src/ui/cdd-service.ts +195 -55
  95. package/src/ui/cdd-types.ts +73 -0
  96. package/dist/ui-web/assets/index-egkMxKos.css +0 -1
@@ -17,6 +17,29 @@ export type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K
17
17
  Model-Definition
18
18
  */
19
19
 
20
+ /**
21
+ * 부모 Entity fixture 생성 시 함께 생성할 companion Entity 설정
22
+ *
23
+ * 예: User fixture 생성 시 credentials Account를 함께 생성
24
+ */
25
+ export type FixtureCompanion = {
26
+ /** 함께 생성할 Entity 이름 */
27
+ entity: string;
28
+
29
+ /**
30
+ * 고정 오버라이드 값.
31
+ * "{{fieldName}}" 형식으로 부모 fixture의 필드 값을 참조할 수 있다.
32
+ * 예: { "account_id": "{{email}}" } → 부모 User의 email 값 사용
33
+ */
34
+ overrides?: Record<string, unknown>;
35
+
36
+ /**
37
+ * 부모 1개당 생성할 companion 개수. 기본값 1.
38
+ * 예: count: 2 → User 1개당 companion 2개 생성
39
+ */
40
+ count?: number;
41
+ };
42
+
20
43
  /**
21
44
  * cone: 범용 메타데이터 시스템
22
45
  *
@@ -31,6 +54,7 @@ export type Cone = {
31
54
  fixtureGenerator?: string; // Faker.js 코드 또는 커스텀 함수
32
55
  fixtureDefault?: unknown; // 기본값
33
56
  fixtureStrategy?: "sequence"; // string 타입이지만 DB sequence로 관리되는 PK (better-auth 등)
57
+ fixtureCompanions?: FixtureCompanion[]; // 부모 fixture 생성 시 함께 생성할 companion Entity 목록
34
58
 
35
59
  // 참조 데이터 관련
36
60
  dataSource?: {
@@ -179,6 +203,14 @@ export type JsonProp = CommonProp & {
179
203
  type: "json";
180
204
  id: string;
181
205
  }; // PG: json / TS: any(id) / JSON: any
206
+ export type SearchTextSourceColumn = {
207
+ name: string;
208
+ caseInsensitive?: boolean;
209
+ };
210
+ export type SearchTextProp = CommonProp & {
211
+ type: "searchText";
212
+ sourceColumns: SearchTextSourceColumn[];
213
+ }; // PG: text (generated) / TS: string / JSON: string
182
214
  export type UuidProp = CommonProp & {
183
215
  type: "uuid";
184
216
  }; // PG: uuid / TS: string / JSON: string
@@ -271,6 +303,7 @@ export type EntityProp =
271
303
  | UuidProp
272
304
  | UuidArrayProp
273
305
  | JsonProp
306
+ | SearchTextProp
274
307
  | VirtualProp
275
308
  | VectorProp
276
309
  | VectorArrayProp
@@ -333,12 +366,26 @@ export type BuiltInTypeId = (typeof BUILT_IN_TYPE_IDS)[number];
333
366
  */
334
367
  export type VectorOps = "vector_cosine_ops" | "vector_ip_ops" | "vector_l2_ops";
335
368
 
369
+ export const KnownOpclassValues = [
370
+ "gin_trgm_ops",
371
+ "gist_trgm_ops",
372
+ "gin_bigm_ops",
373
+ "vector_cosine_ops",
374
+ "vector_ip_ops",
375
+ "vector_l2_ops",
376
+ "pgroonga_varchar_full_text_search_ops_v2",
377
+ "pgroonga_jsonb_full_text_search_ops_v2",
378
+ ] as const;
379
+ export type KnownOpclass = (typeof KnownOpclassValues)[number];
380
+
336
381
  type EntityIndexColumn = {
337
382
  name: string;
338
383
  nullsFirst?: boolean;
339
384
  sortOrder?: "ASC" | "DESC";
340
385
  /** pgvector 인덱스에서 사용할 거리 연산자 (vector 컬럼에만 적용) */
341
386
  vectorOps?: VectorOps;
387
+ /** generic 인덱스 opclass (vectorOps는 하위호환 목적으로 유지) */
388
+ opclass?: KnownOpclass | string;
342
389
  };
343
390
  export type EntityIndex = {
344
391
  type: "index" | "unique" | "hnsw" | "ivfflat";
@@ -598,6 +645,9 @@ export function isUuidProp(p: unknown): p is UuidProp | UuidArrayProp {
598
645
  export function isJsonProp(p: unknown): p is JsonProp {
599
646
  return (p as JsonProp)?.type === "json";
600
647
  }
648
+ export function isSearchTextProp(p: unknown): p is SearchTextProp {
649
+ return (p as SearchTextProp)?.type === "searchText";
650
+ }
601
651
  export function isVirtualProp(p: unknown): p is VirtualProp {
602
652
  return (p as VirtualProp)?.type === "virtual";
603
653
  }
@@ -1041,6 +1091,12 @@ const GeneratedColumnSchema = z.object({
1041
1091
  expression: z.string(),
1042
1092
  });
1043
1093
 
1094
+ const FixtureCompanionSchema = z.object({
1095
+ entity: z.string(),
1096
+ overrides: z.record(z.string(), z.unknown()).optional(),
1097
+ count: z.number().int().positive().optional(),
1098
+ });
1099
+
1044
1100
  /**
1045
1101
  * Cone 스키마 검증
1046
1102
  *
@@ -1053,6 +1109,7 @@ const ConeSchema = z
1053
1109
  fixtureGenerator: z.string().optional(),
1054
1110
  fixtureDefault: z.unknown().optional(),
1055
1111
  fixtureStrategy: z.literal("sequence").optional(),
1112
+ fixtureCompanions: z.array(FixtureCompanionSchema).optional(),
1056
1113
  dataSource: z
1057
1114
  .object({
1058
1115
  strategy: z.enum(["sample", "ids", "query", "file", "recent", "random"]),
@@ -1186,6 +1243,21 @@ const JsonPropSchema = z
1186
1243
  })
1187
1244
  .strict();
1188
1245
 
1246
+ const SearchTextSourceColumnSchema = z
1247
+ .object({
1248
+ name: z.string(),
1249
+ caseInsensitive: z.boolean().optional(),
1250
+ })
1251
+ .strict();
1252
+
1253
+ const SearchTextPropSchema = z
1254
+ .object({
1255
+ ...BasePropFields,
1256
+ type: z.literal("searchText"),
1257
+ sourceColumns: z.array(SearchTextSourceColumnSchema).min(1),
1258
+ })
1259
+ .strict();
1260
+
1189
1261
  const VirtualPropSchema = z
1190
1262
  .object({
1191
1263
  ...BasePropFields,
@@ -1297,6 +1369,7 @@ const NormalPropTypes = [
1297
1369
  "uuid",
1298
1370
  "uuid[]",
1299
1371
  "json",
1372
+ "searchText",
1300
1373
  "virtual",
1301
1374
  "vector",
1302
1375
  "vector[]",
@@ -1335,6 +1408,7 @@ export const NormalPropSchema = z
1335
1408
  NumericPropSchema,
1336
1409
  NumericArrayPropSchema,
1337
1410
  JsonPropSchema,
1411
+ SearchTextPropSchema,
1338
1412
  VirtualPropSchema,
1339
1413
  VectorPropSchema,
1340
1414
  VectorArrayPropSchema,
@@ -1391,6 +1465,7 @@ const EntityIndexColumnSchema = z.object({
1391
1465
  nullsFirst: z.boolean().optional(),
1392
1466
  sortOrder: z.enum(["ASC", "DESC"]).optional(),
1393
1467
  vectorOps: z.enum(["vector_cosine_ops", "vector_ip_ops", "vector_l2_ops"]).optional(),
1468
+ opclass: z.union([z.enum(KnownOpclassValues), z.string().min(1)]).optional(),
1394
1469
  });
1395
1470
 
1396
1471
  // EntityIndex 스키마 정의
@@ -1439,7 +1514,75 @@ const EnumDefSchema = z.union([
1439
1514
  }),
1440
1515
  ]);
1441
1516
 
1442
- export const EntityJsonSchema = z
1517
+ function unwrapSearchTextJsonSourceType(zodType: z.ZodTypeAny): z.ZodTypeAny {
1518
+ let current = zodType;
1519
+
1520
+ while (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
1521
+ current = current.unwrap() as z.ZodTypeAny;
1522
+ }
1523
+
1524
+ return current;
1525
+ }
1526
+
1527
+ export function isSearchTextJsonSourceZodType(zodType: z.ZodTypeAny): boolean {
1528
+ const baseType = unwrapSearchTextJsonSourceType(zodType);
1529
+ if (!(baseType instanceof z.ZodArray)) {
1530
+ return false;
1531
+ }
1532
+
1533
+ const elementType = baseType.def.element;
1534
+ return elementType instanceof z.ZodString;
1535
+ }
1536
+
1537
+ function validateSearchTextSources(
1538
+ props: readonly z.infer<typeof EntityPropSchema>[],
1539
+ ctx: z.RefinementCtx,
1540
+ propsPath: (string | number)[] = ["props"],
1541
+ ): void {
1542
+ const propsByName = new Map(
1543
+ props.map((prop) => {
1544
+ return [prop.name, prop];
1545
+ }),
1546
+ );
1547
+
1548
+ props.forEach((prop, propIndex) => {
1549
+ if (prop.type !== "searchText") {
1550
+ return;
1551
+ }
1552
+
1553
+ prop.sourceColumns.forEach((source, sourceIndex) => {
1554
+ const sourceProp = propsByName.get(source.name);
1555
+ const sourcePath = [...propsPath, propIndex, "sourceColumns", sourceIndex, "name"];
1556
+
1557
+ if (!sourceProp) {
1558
+ ctx.addIssue({
1559
+ code: "custom",
1560
+ message: `searchText source column "${source.name}"을(를) 찾을 수 없습니다.`,
1561
+ path: sourcePath,
1562
+ });
1563
+ return;
1564
+ }
1565
+
1566
+ if (sourceProp.type === "string" || sourceProp.type === "string[]") {
1567
+ return;
1568
+ }
1569
+
1570
+ if (sourceProp.type === "json") {
1571
+ // json source의 최종 타입 유효성은 EntityManager.register() 단계에서
1572
+ // 실제 Zod 타입을 해석하여 구조적으로 검증합니다.
1573
+ return;
1574
+ }
1575
+
1576
+ ctx.addIssue({
1577
+ code: "custom",
1578
+ message: `searchText source column "${source.name}"의 타입 "${sourceProp.type}"은(는) 지원되지 않습니다.`,
1579
+ path: sourcePath,
1580
+ });
1581
+ });
1582
+ });
1583
+ }
1584
+
1585
+ const EntityJsonBaseSchema = z
1443
1586
  .object({
1444
1587
  id: z.string().describe("PascalCase로 된 Entity ID"),
1445
1588
  title: z.string().describe("Entity 이름"),
@@ -1453,18 +1596,31 @@ export const EntityJsonSchema = z
1453
1596
  })
1454
1597
  .strict();
1455
1598
 
1599
+ export const EntityJsonSchema = EntityJsonBaseSchema.superRefine((entity, ctx) => {
1600
+ validateSearchTextSources(entity.props, ctx);
1601
+ });
1602
+
1603
+ const TemplateEntitySchema = EntityJsonBaseSchema.omit({ id: true })
1604
+ .extend({
1605
+ entityId: z.string(),
1606
+ })
1607
+ .partial({
1608
+ table: true,
1609
+ props: true,
1610
+ indexes: true,
1611
+ subsets: true,
1612
+ enums: true,
1613
+ })
1614
+ .superRefine((entity, ctx) => {
1615
+ if (!entity.props) {
1616
+ return;
1617
+ }
1618
+
1619
+ validateSearchTextSources(entity.props, ctx);
1620
+ });
1621
+
1456
1622
  export const TemplateOptions = z.object({
1457
- entity: EntityJsonSchema.omit({ id: true })
1458
- .extend({
1459
- entityId: z.string(),
1460
- })
1461
- .partial({
1462
- table: true,
1463
- props: true,
1464
- indexes: true,
1465
- subsets: true,
1466
- enums: true,
1467
- }),
1623
+ entity: TemplateEntitySchema,
1468
1624
  init_types: z.object({
1469
1625
  entityId: z.string(),
1470
1626
  }),
package/src/ui/api.ts CHANGED
@@ -37,7 +37,15 @@ import {
37
37
  } from "../types/types";
38
38
  import { nonNullable } from "../utils/utils";
39
39
  import { setAiApi } from "./ai-api";
40
- import { editContent, getCddTree, openSourceFile, readContent } from "./cdd-service";
40
+ import {
41
+ editContent,
42
+ editSchema,
43
+ getCddTree,
44
+ listSchemas,
45
+ openSourceFile,
46
+ readContent,
47
+ readSchema,
48
+ } from "./cdd-service";
41
49
 
42
50
  export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
43
51
  fastify.register(
@@ -1415,6 +1423,21 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
1415
1423
  return { success: true };
1416
1424
  });
1417
1425
 
1426
+ // CDD Schema API
1427
+ server.get("/api/cdd/schemas", async () => {
1428
+ return listSchemas();
1429
+ });
1430
+
1431
+ server.post<{ Body: { schemaKey: string } }>("/api/cdd/readSchema", async (request) => {
1432
+ const { schemaKey } = request.body;
1433
+ return readSchema(schemaKey);
1434
+ });
1435
+
1436
+ server.post<{ Body: { schemaKey: string } }>("/api/cdd/editSchema", async (request) => {
1437
+ const { schemaKey } = request.body;
1438
+ return editSchema(schemaKey);
1439
+ });
1440
+
1418
1441
  // ui-web 빌드 파일 서빙
1419
1442
  const uiDistPath = path.resolve(import.meta.dirname, "../ui-web");
1420
1443
 
@@ -1,26 +1,33 @@
1
1
  import { spawn } from "child_process";
2
- import crypto from "crypto";
3
2
  import fs from "fs";
4
3
  import os from "os";
5
4
  import path from "path";
6
5
  import { Sonamu } from "../api/sonamu";
7
-
8
- export type CddFileType = "contract" | "spec";
9
-
10
- export type CddTreeNode = {
11
- name: string;
12
- /** contract/ 기준 상대 경로 */
13
- path: string;
14
- type: "file" | "directory";
15
- /** file인 경우만 존재 */
16
- fileType?: CddFileType;
17
- /** directory인 경우만 존재 */
18
- children?: CddTreeNode[];
19
- };
6
+ import type {
7
+ CddContentEnvelope,
8
+ CddFileType,
9
+ CddSchema,
10
+ CddSchemaDetailEnvelope,
11
+ CddSchemaReference,
12
+ CddSchemaSummary,
13
+ CddTreeNode,
14
+ } from "./cdd-types";
15
+
16
+ export type {
17
+ CddContentEnvelope,
18
+ CddFileType,
19
+ CddSchema,
20
+ CddSchemaDetailEnvelope,
21
+ CddSchemaField,
22
+ CddSchemaFieldType,
23
+ CddSchemaReference,
24
+ CddSchemaSummary,
25
+ CddTreeNode,
26
+ } from "./cdd-types";
20
27
 
21
28
  /** contract/ 디렉터리 절대 경로 반환 (apiRootPath 기준) */
22
29
  function getContractDir(): string {
23
- return path.join(Sonamu.apiRootPath, "contract");
30
+ return path.join(Sonamu.apiRootPath, "..", "..", "contract");
24
31
  }
25
32
 
26
33
  /** 경로가 contract/ 디렉터리 내부인지 검증 */
@@ -82,15 +89,17 @@ export function getCddTree(): { exists: boolean; tree: CddTreeNode[] } {
82
89
  return { exists: true, tree };
83
90
  }
84
91
 
85
- /** content 필드를 string으로 변환 (string[] string 모두 지원) */
86
- function contentToString(content: unknown): string {
87
- if (Array.isArray(content)) return content.join("\n");
88
- if (typeof content === "string") return content;
89
- return "";
92
+ /** schema ID로 schema 파일을 찾아 반환 */
93
+ function resolveSchema(schemaId: string): CddSchema | null {
94
+ const contractDir = getContractDir();
95
+ const schemaPath = path.join(contractDir, "schemas", `${schemaId}.schema.json`);
96
+ if (!fs.existsSync(schemaPath)) return null;
97
+ const raw = fs.readFileSync(schemaPath, "utf-8");
98
+ return JSON.parse(raw) as CddSchema;
90
99
  }
91
100
 
92
- /** JSON 파일의 전체 내용을 읽어 반환 (content는 string으로 변환) */
93
- export function readContent(filePath: string): Record<string, unknown> {
101
+ /** JSON 파일의 전체 내용을 읽어 schema와 함께 envelope로 반환 */
102
+ export function readContent(filePath: string): CddContentEnvelope {
94
103
  assertInsideContractDir(filePath);
95
104
 
96
105
  const contractDir = getContractDir();
@@ -101,11 +110,19 @@ export function readContent(filePath: string): Record<string, unknown> {
101
110
  }
102
111
 
103
112
  const raw = fs.readFileSync(absPath, "utf-8");
104
- const json = JSON.parse(raw) as Record<string, unknown>;
105
- return { ...json, content: contentToString(json.content) };
113
+ const document = JSON.parse(raw) as Record<string, unknown>;
114
+ const fileType = detectFileType(path.basename(filePath));
115
+ const schemaId = typeof document.schema === "string" ? document.schema : null;
116
+ const schema = schemaId ? resolveSchema(schemaId) : null;
117
+
118
+ return {
119
+ document,
120
+ schema,
121
+ fileType: fileType ?? "contract",
122
+ };
106
123
  }
107
124
 
108
- /** JSON 파일의 content 필드를 외부 에디터로 편집 */
125
+ /** JSON 파일을 외부 에디터로 직접 편집 */
109
126
  export async function editContent(
110
127
  filePath: string,
111
128
  ): Promise<{ success: boolean; filePath: string }> {
@@ -119,37 +136,9 @@ export async function editContent(
119
136
  }
120
137
 
121
138
  const editor = resolveEditorCli();
139
+ await runEditor(editor, absPath);
122
140
 
123
- const raw = fs.readFileSync(absPath, "utf-8");
124
- const json: Record<string, unknown> = JSON.parse(raw);
125
-
126
- const content = contentToString(json.content);
127
-
128
- const tmpFileName = `cdd-edit-${crypto.randomUUID()}.md`;
129
- const tmpFilePath = path.join(os.tmpdir(), tmpFileName);
130
-
131
- fs.writeFileSync(tmpFilePath, content, "utf-8");
132
-
133
- try {
134
- await runEditor(editor, tmpFilePath);
135
-
136
- const edited = fs.readFileSync(tmpFilePath, "utf-8");
137
- json.content = edited.split("\n");
138
-
139
- const today = new Date();
140
- const yyyy = today.getFullYear();
141
- const mm = String(today.getMonth() + 1).padStart(2, "0");
142
- const dd = String(today.getDate()).padStart(2, "0");
143
- json.lastModified = `${yyyy}-${mm}-${dd}`;
144
-
145
- fs.writeFileSync(absPath, `${JSON.stringify(json, null, 2)}\n`, "utf-8");
146
-
147
- return { success: true, filePath };
148
- } finally {
149
- if (fs.existsSync(tmpFilePath)) {
150
- fs.unlinkSync(tmpFilePath);
151
- }
152
- }
141
+ return { success: true, filePath };
153
142
  }
154
143
 
155
144
  /** 에디터별 앱 번들 내 CLI 경로 + --wait 플래그 매핑 */
@@ -208,6 +197,157 @@ function runEditor(editor: { bin: string; args: string[] }, filePath: string): P
208
197
  });
209
198
  }
210
199
 
200
+ /* ========================================================================
201
+ * Schema 관리 API
202
+ * ======================================================================== */
203
+
204
+ /** contract/schemas/ 디렉터리 내 .schema.json 파일 경로 목록 반환 */
205
+ function scanSchemaFiles(): { absPath: string; relPath: string; fileName: string }[] {
206
+ const schemasDir = path.join(getContractDir(), "schemas");
207
+ if (!fs.existsSync(schemasDir)) return [];
208
+
209
+ return fs
210
+ .readdirSync(schemasDir, { withFileTypes: true })
211
+ .filter((e) => e.isFile() && e.name.endsWith(".schema.json"))
212
+ .map((e) => ({
213
+ absPath: path.join(schemasDir, e.name),
214
+ relPath: `schemas/${e.name}`,
215
+ fileName: e.name,
216
+ }));
217
+ }
218
+
219
+ /** 특정 schemaId를 참조하는 contract/spec 문서들을 재귀 수집 */
220
+ function collectSchemaReferences(
221
+ schemaId: string,
222
+ dirPath: string,
223
+ relativeTo: string,
224
+ ): CddSchemaReference[] {
225
+ if (!fs.existsSync(dirPath)) return [];
226
+ const refs: CddSchemaReference[] = [];
227
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
228
+
229
+ for (const entry of entries) {
230
+ const fullPath = path.join(dirPath, entry.name);
231
+ if (entry.isDirectory()) {
232
+ if (entry.name === "schemas") continue;
233
+ refs.push(...collectSchemaReferences(schemaId, fullPath, relativeTo));
234
+ } else if (entry.isFile()) {
235
+ const fileType = detectFileType(entry.name);
236
+ if (!fileType) continue;
237
+ try {
238
+ const raw = fs.readFileSync(fullPath, "utf-8");
239
+ const doc = JSON.parse(raw) as Record<string, unknown>;
240
+ if (doc.schema === schemaId) {
241
+ refs.push({
242
+ path: path.relative(relativeTo, fullPath),
243
+ fileType,
244
+ name: entry.name,
245
+ });
246
+ }
247
+ } catch {
248
+ // JSON 파싱 실패 시 무시
249
+ }
250
+ }
251
+ }
252
+ return refs;
253
+ }
254
+
255
+ /** schema 파일명에서 기대되는 id 추출 */
256
+ function expectedSchemaId(fileName: string): string {
257
+ return fileName.replace(/\.schema\.json$/, "");
258
+ }
259
+
260
+ /** schemas/ 하위 경로가 contract/schemas/ 내부인지 검증 */
261
+ function assertInsideSchemaDir(schemaKey: string): void {
262
+ const schemasDir = path.join(getContractDir(), "schemas");
263
+ const resolved = path.resolve(schemasDir, `${schemaKey}.schema.json`);
264
+ if (!resolved.startsWith(schemasDir + path.sep) && resolved !== schemasDir) {
265
+ throw new Error(`유효하지 않은 스키마 키입니다: ${schemaKey}`);
266
+ }
267
+ }
268
+
269
+ /** schema 목록 반환 */
270
+ export function listSchemas(): { schemas: CddSchemaSummary[] } {
271
+ const contractDir = getContractDir();
272
+ const files = scanSchemaFiles();
273
+ const schemas: CddSchemaSummary[] = [];
274
+
275
+ for (const file of files) {
276
+ const key = expectedSchemaId(file.fileName);
277
+ try {
278
+ const raw = fs.readFileSync(file.absPath, "utf-8");
279
+ const schema = JSON.parse(raw) as CddSchema;
280
+ const refs = collectSchemaReferences(schema.id, contractDir, contractDir);
281
+ schemas.push({
282
+ key,
283
+ id: schema.id,
284
+ path: file.relPath,
285
+ type: schema.type,
286
+ fieldCount: schema.fields.length,
287
+ referenceCount: refs.length,
288
+ hasIdMismatch: schema.id !== key,
289
+ });
290
+ } catch (err) {
291
+ schemas.push({
292
+ key,
293
+ id: key,
294
+ path: file.relPath,
295
+ type: "contract",
296
+ fieldCount: 0,
297
+ referenceCount: 0,
298
+ hasIdMismatch: false,
299
+ parseError: err instanceof Error ? err.message : String(err),
300
+ });
301
+ }
302
+ }
303
+
304
+ return { schemas };
305
+ }
306
+
307
+ /** schema 상세 반환 (파일명 기반 key로 조회) */
308
+ export function readSchema(schemaKey: string): CddSchemaDetailEnvelope {
309
+ assertInsideSchemaDir(schemaKey);
310
+
311
+ const contractDir = getContractDir();
312
+ const absPath = path.join(contractDir, "schemas", `${schemaKey}.schema.json`);
313
+
314
+ if (!fs.existsSync(absPath)) {
315
+ throw new Error(`스키마를 찾을 수 없습니다: ${schemaKey}`);
316
+ }
317
+
318
+ const raw = fs.readFileSync(absPath, "utf-8");
319
+ const schema = JSON.parse(raw) as CddSchema;
320
+ const relPath = path.relative(contractDir, absPath);
321
+ const references = collectSchemaReferences(schema.id, contractDir, contractDir);
322
+
323
+ return {
324
+ key: schemaKey,
325
+ path: relPath,
326
+ schema,
327
+ references,
328
+ hasIdMismatch: schema.id !== schemaKey,
329
+ };
330
+ }
331
+
332
+ /** schema 파일을 외부 에디터로 편집 (파일명 기반 key로 조회) */
333
+ export async function editSchema(
334
+ schemaKey: string,
335
+ ): Promise<{ success: boolean; schemaKey: string }> {
336
+ assertInsideSchemaDir(schemaKey);
337
+
338
+ const contractDir = getContractDir();
339
+ const absPath = path.join(contractDir, "schemas", `${schemaKey}.schema.json`);
340
+
341
+ if (!fs.existsSync(absPath)) {
342
+ throw new Error(`스키마를 찾을 수 없습니다: ${schemaKey}`);
343
+ }
344
+
345
+ const editor = resolveEditorCli();
346
+ await runEditor(editor, absPath);
347
+
348
+ return { success: true, schemaKey };
349
+ }
350
+
211
351
  /** 소스 파일을 외부 에디터로 열기 (대기하지 않음) */
212
352
  export function openSourceFile(filePath: string): void {
213
353
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(Sonamu.apiRootPath, filePath);
@@ -0,0 +1,73 @@
1
+ export type CddFileType = "contract" | "spec";
2
+
3
+ export type CddSchemaFieldType =
4
+ | "string"
5
+ | "string[]"
6
+ | "Record<string, string>"
7
+ | "Record<string, object>";
8
+
9
+ export type CddSchemaField = {
10
+ name: string;
11
+ label?: string;
12
+ type: CddSchemaFieldType;
13
+ renderer?: string;
14
+ required: boolean;
15
+ };
16
+
17
+ export type CddSchema = {
18
+ id: string;
19
+ type: "contract" | "spec";
20
+ fields: CddSchemaField[];
21
+ };
22
+
23
+ export type CddContentEnvelope = {
24
+ document: Record<string, unknown>;
25
+ schema: CddSchema | null;
26
+ fileType: CddFileType;
27
+ };
28
+
29
+ export type CddTreeNode = {
30
+ name: string;
31
+ path: string;
32
+ type: "file" | "directory";
33
+ fileType?: CddFileType;
34
+ children?: CddTreeNode[];
35
+ };
36
+
37
+ export type CddSchemaSummary = {
38
+ key: string;
39
+ id: string;
40
+ path: string;
41
+ type: "contract" | "spec";
42
+ fieldCount: number;
43
+ referenceCount: number;
44
+ hasIdMismatch: boolean;
45
+ parseError?: string;
46
+ };
47
+
48
+ export type CddSchemaReference = {
49
+ path: string;
50
+ fileType: CddFileType;
51
+ name: string;
52
+ };
53
+
54
+ export type CddSchemaDetailEnvelope = {
55
+ key: string;
56
+ path: string;
57
+ schema: CddSchema;
58
+ references: CddSchemaReference[];
59
+ hasIdMismatch: boolean;
60
+ };
61
+
62
+ /** Acceptance Criterion 테스트 참조 */
63
+ export type AcceptanceCriterionTestRef = {
64
+ target: string;
65
+ pattern: string;
66
+ };
67
+
68
+ /** 구조화된 Acceptance Criterion */
69
+ export type AcceptanceCriterion = {
70
+ id: string;
71
+ condition: string;
72
+ testRef: AcceptanceCriterionTestRef;
73
+ };