sonamu 0.10.13 → 0.11.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.
Files changed (230) hide show
  1. package/dist/ai/agents/agent.d.ts.map +1 -1
  2. package/dist/ai/providers/rtzr/error.d.ts.map +1 -1
  3. package/dist/ai/providers/rtzr/model.d.ts.map +1 -1
  4. package/dist/ai/providers/rtzr/utils.d.ts.map +1 -1
  5. package/dist/api/base-frame.d.ts.map +1 -1
  6. package/dist/api/decorators.d.ts.map +1 -1
  7. package/dist/api/sonamu.d.ts.map +1 -1
  8. package/dist/api/sonamu.js +3 -2
  9. package/dist/api/validator.d.ts.map +1 -1
  10. package/dist/auth/audit-log/events.d.ts.map +1 -1
  11. package/dist/auth/index.d.ts +1 -1
  12. package/dist/auth/index.d.ts.map +1 -1
  13. package/dist/auth/index.js +3 -3
  14. package/dist/auth/knex-adapter.d.ts.map +1 -1
  15. package/dist/auth/plugins/wrappers/admin.d.ts +4 -4
  16. package/dist/auth/plugins/wrappers/admin.d.ts.map +1 -1
  17. package/dist/auth/plugins/wrappers/anonymous.d.ts.map +1 -1
  18. package/dist/auth/plugins/wrappers/api-key.d.ts.map +1 -1
  19. package/dist/auth/plugins/wrappers/jwt.d.ts.map +1 -1
  20. package/dist/auth/plugins/wrappers/organization.d.ts.map +1 -1
  21. package/dist/auth/plugins/wrappers/passkey.d.ts.map +1 -1
  22. package/dist/auth/plugins/wrappers/phone-number.d.ts.map +1 -1
  23. package/dist/auth/plugins/wrappers/sso.d.ts.map +1 -1
  24. package/dist/auth/plugins/wrappers/two-factor.d.ts.map +1 -1
  25. package/dist/auth/plugins/wrappers/username.d.ts.map +1 -1
  26. package/dist/bin/build-config.d.ts +10 -0
  27. package/dist/bin/build-config.d.ts.map +1 -1
  28. package/dist/bin/build-config.js +130 -6
  29. package/dist/bin/fixture.d.ts +7 -4
  30. package/dist/bin/fixture.d.ts.map +1 -1
  31. package/dist/bin/fixture.js +161 -13
  32. package/dist/bin/test-command.d.ts +7 -0
  33. package/dist/bin/test-command.d.ts.map +1 -1
  34. package/dist/bin/test-command.js +18 -4
  35. package/dist/bin/typecheck-web.d.ts +2 -0
  36. package/dist/bin/typecheck-web.d.ts.map +1 -0
  37. package/dist/bin/typecheck-web.js +36 -0
  38. package/dist/cache/decorator.d.ts.map +1 -1
  39. package/dist/cache/drivers.d.ts.map +1 -1
  40. package/dist/cache-control/cache-control.d.ts.map +1 -1
  41. package/dist/compress/compress.d.ts.map +1 -1
  42. package/dist/cone/cone-generator.js +1 -1
  43. package/dist/database/base-model.d.ts.map +1 -1
  44. package/dist/database/base-model.types.d.ts +0 -1
  45. package/dist/database/base-model.types.d.ts.map +1 -1
  46. package/dist/database/db.d.ts.map +1 -1
  47. package/dist/database/puri-subset.types.d.ts +0 -1
  48. package/dist/database/puri-subset.types.d.ts.map +1 -1
  49. package/dist/database/puri-wrapper.d.ts +0 -1
  50. package/dist/database/puri-wrapper.d.ts.map +1 -1
  51. package/dist/database/puri.d.ts +0 -1
  52. package/dist/database/puri.d.ts.map +1 -1
  53. package/dist/database/puri.types.d.ts +0 -1
  54. package/dist/database/puri.types.d.ts.map +1 -1
  55. package/dist/database/transaction-context.d.ts.map +1 -1
  56. package/dist/database/upsert-builder.d.ts.map +1 -1
  57. package/dist/dict/en.d.ts +58 -58
  58. package/dist/dict/en.d.ts.map +1 -1
  59. package/dist/dict/ko.d.ts +58 -58
  60. package/dist/dict/ko.d.ts.map +1 -1
  61. package/dist/dict/rc-keys.d.ts.map +1 -1
  62. package/dist/dict/sonamu-dictionary.d.ts.map +1 -1
  63. package/dist/dict/sonamu-dictionary.js +2 -2
  64. package/dist/dict/utils.d.ts.map +1 -1
  65. package/dist/entity/entity-manager.d.ts +115 -113
  66. package/dist/entity/entity-manager.d.ts.map +1 -1
  67. package/dist/entity/entity-manager.js +25 -19
  68. package/dist/entity/entity-template-cone.d.ts +4 -4
  69. package/dist/entity/entity.d.ts +6 -1
  70. package/dist/entity/entity.d.ts.map +1 -1
  71. package/dist/entity/entity.js +8 -3
  72. package/dist/env.d.ts.map +1 -1
  73. package/dist/exceptions/so-exceptions.d.ts.map +1 -1
  74. package/dist/filter/types.d.ts.map +1 -1
  75. package/dist/index.js +6 -6
  76. package/dist/logger/configure.d.ts +10 -1
  77. package/dist/logger/configure.d.ts.map +1 -1
  78. package/dist/logger/configure.js +30 -5
  79. package/dist/migration/migrator.d.ts +12 -1
  80. package/dist/migration/migrator.d.ts.map +1 -1
  81. package/dist/migration/migrator.js +77 -30
  82. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  83. package/dist/migration/slack-confirm.d.ts.map +1 -1
  84. package/dist/naite/messaging-types.d.ts +0 -1
  85. package/dist/naite/messaging-types.d.ts.map +1 -1
  86. package/dist/naite/naite-reporter.d.ts +0 -1
  87. package/dist/naite/naite-reporter.d.ts.map +1 -1
  88. package/dist/naite/naite.d.ts.map +1 -1
  89. package/dist/storage/base-file.d.ts.map +1 -1
  90. package/dist/storage/buffered-file.d.ts.map +1 -1
  91. package/dist/storage/drivers.d.ts.map +1 -1
  92. package/dist/storage/s3-driver.d.ts.map +1 -1
  93. package/dist/storage/storage-manager.d.ts.map +1 -1
  94. package/dist/storage/uploaded-file.d.ts.map +1 -1
  95. package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
  96. package/dist/stream/ws-audience.d.ts.map +1 -1
  97. package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
  98. package/dist/stream/ws-delivery.d.ts.map +1 -1
  99. package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
  100. package/dist/stream/ws-presence-store.d.ts.map +1 -1
  101. package/dist/stream/ws-registry.d.ts.map +1 -1
  102. package/dist/stream/ws-telemetry-memory.d.ts.map +1 -1
  103. package/dist/stream/ws-telemetry.d.ts.map +1 -1
  104. package/dist/stream/ws.d.ts.map +1 -1
  105. package/dist/syncer/api-parser.js +2 -2
  106. package/dist/syncer/file-patterns.d.ts +17 -17
  107. package/dist/syncer/filesystem-dependencies.js +1 -1
  108. package/dist/syncer/syncer.d.ts.map +1 -1
  109. package/dist/tasks/step-wrapper.d.ts.map +1 -1
  110. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  111. package/dist/template/implementations/entity.template.d.ts +2 -2
  112. package/dist/template/implementations/entity.template.d.ts.map +1 -1
  113. package/dist/template/implementations/entry-server.template.d.ts +2 -2
  114. package/dist/template/implementations/entry-server.template.d.ts.map +1 -1
  115. package/dist/template/implementations/generated.template.d.ts +2 -2
  116. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  117. package/dist/template/implementations/generated_http.template.d.ts +2 -2
  118. package/dist/template/implementations/generated_http.template.d.ts.map +1 -1
  119. package/dist/template/implementations/generated_sso.template.d.ts +2 -2
  120. package/dist/template/implementations/generated_sso.template.d.ts.map +1 -1
  121. package/dist/template/implementations/http_validators.template.d.ts +2 -2
  122. package/dist/template/implementations/http_validators.template.d.ts.map +1 -1
  123. package/dist/template/implementations/init_types.template.d.ts +2 -2
  124. package/dist/template/implementations/init_types.template.d.ts.map +1 -1
  125. package/dist/template/implementations/model.template.d.ts +2 -2
  126. package/dist/template/implementations/model.template.d.ts.map +1 -1
  127. package/dist/template/implementations/model_test.template.d.ts +2 -2
  128. package/dist/template/implementations/model_test.template.d.ts.map +1 -1
  129. package/dist/template/implementations/queries.template.d.ts +2 -2
  130. package/dist/template/implementations/queries.template.d.ts.map +1 -1
  131. package/dist/template/implementations/sd.template.d.ts +2 -2
  132. package/dist/template/implementations/sd.template.d.ts.map +1 -1
  133. package/dist/template/implementations/sd.template.js +1 -1
  134. package/dist/template/implementations/services.template.d.ts +2 -2
  135. package/dist/template/implementations/services.template.d.ts.map +1 -1
  136. package/dist/template/implementations/view_form.template.d.ts +3 -3
  137. package/dist/template/implementations/view_form.template.d.ts.map +1 -1
  138. package/dist/template/implementations/view_id_all_select.template.d.ts +2 -2
  139. package/dist/template/implementations/view_id_all_select.template.d.ts.map +1 -1
  140. package/dist/template/implementations/view_list.template.d.ts +2 -2
  141. package/dist/template/implementations/view_list.template.d.ts.map +1 -1
  142. package/dist/template/implementations/view_search_input.template.d.ts +2 -2
  143. package/dist/template/implementations/view_search_input.template.d.ts.map +1 -1
  144. package/dist/template/template-manager.d.ts.map +1 -1
  145. package/dist/template/template.d.ts.map +1 -1
  146. package/dist/template/zod-converter.d.ts.map +1 -1
  147. package/dist/testing/_relation-graph.d.ts.map +1 -1
  148. package/dist/testing/data-explorer.d.ts +2 -2
  149. package/dist/testing/data-explorer.d.ts.map +1 -1
  150. package/dist/testing/dev-vitest-manager.d.ts.map +1 -1
  151. package/dist/testing/faker-mappings.d.ts.map +1 -1
  152. package/dist/testing/fixture-generator.d.ts.map +1 -1
  153. package/dist/testing/fixture-generator.js +1 -1
  154. package/dist/testing/fixture-loader.d.ts.map +1 -1
  155. package/dist/testing/fixture-manager.d.ts +4 -0
  156. package/dist/testing/fixture-manager.d.ts.map +1 -1
  157. package/dist/testing/fixture-manager.js +99 -25
  158. package/dist/testing/naite-vitest-reporter.d.ts.map +1 -1
  159. package/dist/testing/parallel-db-manager.d.ts.map +1 -1
  160. package/dist/tooling/__tests__/helpers/sonamu-test-root.d.ts +10 -0
  161. package/dist/tooling/__tests__/helpers/sonamu-test-root.d.ts.map +1 -0
  162. package/dist/tooling/__tests__/helpers/sonamu-test-root.js +42 -0
  163. package/dist/tooling/cdd-service.d.ts +87 -0
  164. package/dist/tooling/cdd-service.d.ts.map +1 -0
  165. package/dist/tooling/cdd-service.js +215 -0
  166. package/dist/tooling/cli-tooling.d.ts +11 -0
  167. package/dist/tooling/cli-tooling.d.ts.map +1 -0
  168. package/dist/tooling/cli-tooling.js +901 -0
  169. package/dist/tooling/fixture-service.d.ts +29 -0
  170. package/dist/tooling/fixture-service.d.ts.map +1 -0
  171. package/dist/tooling/fixture-service.js +53 -0
  172. package/dist/tooling/index.d.ts +2 -0
  173. package/dist/tooling/index.d.ts.map +1 -0
  174. package/dist/tooling/index.js +2 -0
  175. package/dist/tooling/test-run-client.d.ts +27 -0
  176. package/dist/tooling/test-run-client.d.ts.map +1 -0
  177. package/dist/tooling/test-run-client.js +120 -0
  178. package/dist/tsdown.api.config.js +48 -0
  179. package/dist/types/types.d.ts +527 -527
  180. package/dist/types/types.d.ts.map +1 -1
  181. package/dist/ui/ai-client.d.ts.map +1 -1
  182. package/dist/utils/formatter.d.ts +6 -0
  183. package/dist/utils/formatter.d.ts.map +1 -1
  184. package/dist/utils/formatter.js +42 -11
  185. package/dist/utils/model.d.ts +1 -1
  186. package/dist/vector/chunking.d.ts.map +1 -1
  187. package/dist/vector/embedding.d.ts.map +1 -1
  188. package/package.json +13 -12
  189. package/src/api/sonamu.ts +2 -1
  190. package/src/auth/index.ts +1 -1
  191. package/src/bin/__tests__/zod-compiler-build.test.ts +67 -0
  192. package/src/bin/build-config.ts +151 -5
  193. package/src/bin/fixture.ts +204 -21
  194. package/src/bin/test-command.ts +27 -4
  195. package/src/bin/typecheck-web.ts +56 -0
  196. package/src/dict/sonamu-dictionary.ts +1 -1
  197. package/src/entity/__tests__/entity-manager-autoload.contract.test.ts +55 -0
  198. package/src/entity/__tests__/entity-manager-explicit-root-registration.contract.test.ts +107 -0
  199. package/src/entity/entity-manager.ts +31 -19
  200. package/src/entity/entity.ts +7 -2
  201. package/src/logger/__tests__/configure.test.ts +33 -0
  202. package/src/logger/configure.ts +45 -3
  203. package/src/migration/__tests__/migrator.test.ts +51 -0
  204. package/src/migration/migrator.ts +103 -34
  205. package/src/syncer/api-parser.ts +1 -1
  206. package/src/testing/fixture-manager.ts +124 -32
  207. package/src/tooling/__tests__/cdd-rule-bootstrap.contract.test.ts +118 -0
  208. package/src/tooling/__tests__/cli-tooling-auth.contract.test.ts +72 -0
  209. package/src/tooling/__tests__/cli-tooling-cone-locale.contract.test.ts +150 -0
  210. package/src/tooling/__tests__/cli-tooling-entity-patch.contract.test.ts +131 -0
  211. package/src/tooling/__tests__/cli-tooling-fixture-transfer.contract.test.ts +255 -0
  212. package/src/tooling/__tests__/cli-tooling-i18n-update.contract.test.ts +104 -0
  213. package/src/tooling/__tests__/cli-tooling-scaffold.contract.test.ts +86 -0
  214. package/src/tooling/__tests__/cli-tooling-stub-practice.contract.test.ts +147 -0
  215. package/src/tooling/__tests__/default-cdd-adapter.contract.test.ts +73 -0
  216. package/src/tooling/__tests__/helpers/sonamu-test-root.ts +42 -0
  217. package/src/tooling/__tests__/test-run-client-config.contract.test.ts +154 -0
  218. package/src/tooling/cdd-service.ts +256 -0
  219. package/src/tooling/cli-tooling.ts +937 -0
  220. package/src/tooling/fixture-service.ts +72 -0
  221. package/src/tooling/index.ts +1 -0
  222. package/src/tooling/test-run-client.ts +196 -0
  223. package/src/utils/__tests__/formatter-bin.test.ts +99 -0
  224. package/src/utils/formatter.ts +74 -10
  225. package/tsdown.api.config.ts +8 -10
  226. package/bin/cli.js +0 -75
  227. package/dist/bin/cli.d.ts +0 -2
  228. package/dist/bin/cli.d.ts.map +0 -1
  229. package/dist/bin/cli.js +0 -956
  230. package/src/bin/cli.ts +0 -1166
@@ -0,0 +1,147 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import { tooling } from "../cli-tooling";
7
+ import { attachSonamuTestRoot, detachSonamuTestRoot } from "./helpers/sonamu-test-root";
8
+
9
+ let apiRoot = "";
10
+ let practiceDir = "";
11
+
12
+ // 구 CLI가 만들던 practice 스텁 본문을 그대로 재현합니다.
13
+ function expectedCode(fileName: string): string {
14
+ return [
15
+ `import { Sonamu } from "sonamu";`,
16
+ "",
17
+ `console.clear();`,
18
+ `console.log("${fileName}");`,
19
+ "",
20
+ `Sonamu.runScript(async () => {`,
21
+ ` // TODO`,
22
+ `});`,
23
+ "",
24
+ ].join("\n");
25
+ }
26
+
27
+ async function runPractice(name: string) {
28
+ expect(tooling.stub).toBeDefined();
29
+ expect(tooling.stub.practice).toBeTypeOf("function");
30
+ return tooling.stub.practice({ name });
31
+ }
32
+
33
+ async function seedPracticeFiles(fileNames: string[]): Promise<void> {
34
+ await mkdir(practiceDir, { recursive: true });
35
+ await Promise.all(
36
+ fileNames.map((fileName) => writeFile(path.join(practiceDir, fileName), "", "utf8")),
37
+ );
38
+ }
39
+
40
+ beforeEach(async () => {
41
+ const apiRootPath = await attachSonamuTestRoot();
42
+ apiRoot = apiRootPath;
43
+ practiceDir = path.join(apiRootPath, "src", "practices");
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await detachSonamuTestRoot();
48
+ });
49
+
50
+ describe("stub.practice tooling 연산", () => {
51
+ it("practices 디렉터리가 없으면 만들고 첫 번째 파일을 생성한다", async () => {
52
+ await expect(runPractice("hello")).resolves.toMatchObject({
53
+ fileName: "p1-hello.ts",
54
+ path: path.join(practiceDir, "p1-hello.ts"),
55
+ });
56
+
57
+ await expect(readFile(path.join(practiceDir, "p1-hello.ts"), "utf8")).resolves.toBe(
58
+ expectedCode("p1-hello.ts"),
59
+ );
60
+ });
61
+
62
+ it("기존 practice 파일의 최대 시퀀스 다음 번호를 사용한다", async () => {
63
+ await seedPracticeFiles(["p1-first.ts", "p3-third.ts"]);
64
+
65
+ await expect(runPractice("fourth")).resolves.toMatchObject({ fileName: "p4-fourth.ts" });
66
+
67
+ await expect(readFile(path.join(practiceDir, "p4-fourth.ts"), "utf8")).resolves.toBe(
68
+ expectedCode("p4-fourth.ts"),
69
+ );
70
+ });
71
+
72
+ it("시퀀스 규칙에 맞지 않는 파일은 번호 계산에서 제외한다", async () => {
73
+ await seedPracticeFiles(["p2-second.ts", "p9-ninth.js", "readme.md", "practice.ts"]);
74
+
75
+ await expect(runPractice("third")).resolves.toMatchObject({ fileName: "p3-third.ts" });
76
+ });
77
+
78
+ it("허용 문자로만 이루어진 이름은 파일명과 경로를 그대로 돌려준다", async () => {
79
+ await expect(runPractice("my.practice-01_v2")).resolves.toEqual({
80
+ fileName: "p1-my.practice-01_v2.ts",
81
+ path: path.join(practiceDir, "p1-my.practice-01_v2.ts"),
82
+ });
83
+
84
+ await expect(readFile(path.join(practiceDir, "p1-my.practice-01_v2.ts"), "utf8")).resolves.toBe(
85
+ expectedCode("p1-my.practice-01_v2.ts"),
86
+ );
87
+ });
88
+
89
+ // 시퀀스가 double 정밀도 한계까지 커지면 다음 번호가 현재 최댓값과 같아져
90
+ // 계산된 파일명이 이미 존재하는 파일과 충돌합니다. 이때 덮어쓰면 안 됩니다.
91
+ it("계산한 파일명이 이미 있으면 덮어쓰지 않고 PRACTICE_FILE_EXISTS로 거절한다", async () => {
92
+ const collidingFileName = "p10000000000000000000-collide.ts";
93
+ await seedPracticeFiles([]);
94
+ await writeFile(path.join(practiceDir, collidingFileName), "기존 내용", "utf8");
95
+
96
+ await expect(runPractice("collide")).rejects.toMatchObject({
97
+ code: "PRACTICE_FILE_EXISTS",
98
+ exitCode: 2,
99
+ });
100
+
101
+ await expect(readFile(path.join(practiceDir, collidingFileName), "utf8")).resolves.toBe(
102
+ "기존 내용",
103
+ );
104
+ await expect(readdir(practiceDir)).resolves.toEqual([collidingFileName]);
105
+ });
106
+
107
+ it("상위 경로를 노리는 이름은 거절하고 루트 밖 파일을 건드리지 않는다", async () => {
108
+ const victimPath = path.join(apiRoot, "src", "victim.ts");
109
+ await writeFile(victimPath, "피해자 원본", "utf8");
110
+ // p1-../../../victim.ts는 practices 밖(api/src/victim.ts)으로 정규화되는 이름입니다.
111
+ const escapingName = "../../../victim";
112
+ expect(path.resolve(practiceDir, `p1-${escapingName}.ts`)).toBe(victimPath);
113
+
114
+ await expect(runPractice(escapingName)).rejects.toMatchObject({
115
+ code: "INVALID_PRACTICE_NAME",
116
+ exitCode: 2,
117
+ });
118
+
119
+ await expect(readFile(victimPath, "utf8")).resolves.toBe("피해자 원본");
120
+ // 이름 검증이 파일 시스템 접근보다 먼저이므로 practices 디렉터리도 만들지 않습니다.
121
+ await expect(readdir(practiceDir)).rejects.toMatchObject({ code: "ENOENT" });
122
+ });
123
+
124
+ it.each([
125
+ ["큰따옴표가 섞인 이름", 'evil");console.log("'],
126
+ ["경로 구분자가 섞인 이름", "nested/practice"],
127
+ ["역슬래시가 섞인 이름", "escape\\practice"],
128
+ ["점으로 시작하는 이름", "..hidden"],
129
+ ["빈 문자열이 아닌 공백 포함 이름", "two words"],
130
+ ])("%s은 INVALID_PRACTICE_NAME으로 거절하고 아무 파일도 쓰지 않는다", async (_label, name) => {
131
+ await expect(runPractice(name)).rejects.toMatchObject({
132
+ code: "INVALID_PRACTICE_NAME",
133
+ exitCode: 2,
134
+ });
135
+
136
+ await expect(readdir(practiceDir)).rejects.toMatchObject({ code: "ENOENT" });
137
+ });
138
+
139
+ it("name이 없으면 인자 오류로 실패한다", async () => {
140
+ expect(tooling.stub).toBeDefined();
141
+
142
+ await expect(tooling.stub.practice({})).rejects.toMatchObject({
143
+ code: "MISSING_ARGUMENT",
144
+ exitCode: 2,
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,73 @@
1
+ import * as fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { createDefaultCddToolingAdapter } from "../cdd-service";
8
+
9
+ const temporaryDirectories: string[] = [];
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(
13
+ temporaryDirectories.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
14
+ );
15
+ });
16
+
17
+ async function createFixture() {
18
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "sonamu-default-cdd-"));
19
+ temporaryDirectories.push(workspace);
20
+ const contractRoot = path.join(workspace, "contract");
21
+ const outsideRoot = path.join(workspace, "outside");
22
+ await Promise.all([fs.mkdir(contractRoot), fs.mkdir(outsideRoot)]);
23
+ return { contractRoot, outsideRoot };
24
+ }
25
+
26
+ describe("기본 CDD tooling adapter", () => {
27
+ it("주입한 root와 fs로 내부 일반 파일을 읽고 수정한다", async () => {
28
+ const { contractRoot } = await createFixture();
29
+ const document = path.join(contractRoot, "order.contract.md");
30
+ await fs.writeFile(document, "# 주문\n", "utf8");
31
+ const adapter = createDefaultCddToolingAdapter({ contractRoot, fs });
32
+
33
+ await expect(adapter.read({ path: "order.contract.md" })).resolves.toEqual({
34
+ content: "# 주문\n",
35
+ fileType: "contract",
36
+ });
37
+ await adapter.addAcceptanceCriterion({
38
+ document: "order.contract.md",
39
+ text: "승인된 주문만 처리한다",
40
+ dryRun: false,
41
+ });
42
+ await expect(fs.readFile(document, "utf8")).resolves.toContain("승인된 주문만 처리한다");
43
+ });
44
+
45
+ it("외부 파일 symlink 읽기를 기본 adapter 경로에서도 거절한다", async () => {
46
+ const { contractRoot, outsideRoot } = await createFixture();
47
+ const outside = path.join(outsideRoot, "secret.contract.md");
48
+ await fs.writeFile(outside, "외부 비밀", "utf8");
49
+ await fs.symlink(outside, path.join(contractRoot, "linked.contract.md"));
50
+ const adapter = createDefaultCddToolingAdapter({ contractRoot, fs });
51
+
52
+ await expect(adapter.read({ path: "linked.contract.md" })).rejects.toMatchObject({
53
+ code: "INVALID_CDD_PATH",
54
+ });
55
+ });
56
+
57
+ it("외부 파일 symlink 쓰기를 거절하고 원본을 보존한다", async () => {
58
+ const { contractRoot, outsideRoot } = await createFixture();
59
+ const outside = path.join(outsideRoot, "protected.contract.md");
60
+ await fs.writeFile(outside, "보존할 내용\n", "utf8");
61
+ await fs.symlink(outside, path.join(contractRoot, "linked.contract.md"));
62
+ const adapter = createDefaultCddToolingAdapter({ contractRoot, fs });
63
+
64
+ await expect(
65
+ adapter.addAcceptanceCriterion({
66
+ document: "linked.contract.md",
67
+ text: "변조",
68
+ dryRun: false,
69
+ }),
70
+ ).rejects.toMatchObject({ code: "INVALID_CDD_PATH" });
71
+ await expect(fs.readFile(outside, "utf8")).resolves.toBe("보존할 내용\n");
72
+ });
73
+ });
@@ -0,0 +1,42 @@
1
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { Sonamu, type SonamuTestingSnapshot } from "../../../api/sonamu";
6
+ import { type AbsolutePath } from "../../../utils/path-utils";
7
+
8
+ const createdWorkspaces: string[] = [];
9
+ let capturedSnapshot: SonamuTestingSnapshot | null = null;
10
+ let capturedInitialized = false;
11
+
12
+ /**
13
+ * 임시 api root를 만들어 Sonamu singleton의 공개 setter로 주입합니다.
14
+ *
15
+ * tooling 연산은 `Sonamu.isInitialized`가 참이면 초기화를 건너뛰므로,
16
+ * 실제 DB/설정 없이 파일 경로 기반 동작만 검증할 수 있습니다.
17
+ */
18
+ export async function attachSonamuTestRoot(): Promise<string> {
19
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "sonamu-tooling-"));
20
+ createdWorkspaces.push(workspace);
21
+ const apiRootPath = path.join(workspace, "api");
22
+ await mkdir(path.join(apiRootPath, "src", "application"), { recursive: true });
23
+
24
+ capturedSnapshot ??= Sonamu.captureTestingSnapshot();
25
+ capturedInitialized = Sonamu.isInitialized;
26
+ // SAFETY: mkdtemp가 반환한 절대 경로만 주입합니다.
27
+ Sonamu.apiRootPath = apiRootPath as AbsolutePath;
28
+ Sonamu.isInitialized = true;
29
+ return apiRootPath;
30
+ }
31
+
32
+ /** 주입한 Sonamu 상태를 되돌리고 임시 디렉터리를 정리합니다. */
33
+ export async function detachSonamuTestRoot(): Promise<void> {
34
+ if (capturedSnapshot !== null) {
35
+ Sonamu.restoreTestingSnapshot(capturedSnapshot);
36
+ capturedSnapshot = null;
37
+ }
38
+ Sonamu.isInitialized = capturedInitialized;
39
+ await Promise.all(
40
+ createdWorkspaces.splice(0).map((workspace) => rm(workspace, { recursive: true, force: true })),
41
+ );
42
+ }
@@ -0,0 +1,154 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { createServer, type Server } from "node:http";
3
+ import { type AddressInfo } from "node:net";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+
9
+ import { createDefaultTestRunClient } from "../test-run-client";
10
+
11
+ const STATUS_PAYLOAD = { running: false };
12
+ const RUN_RESULT = {
13
+ ok: true,
14
+ summary: { total: 1, passed: 1, failed: 0, skipped: 0, durationMs: 12 },
15
+ results: [],
16
+ };
17
+
18
+ const originalVitest = process.env.VITEST;
19
+ const originalHot = process.env.HOT;
20
+ const temporaryRoots: string[] = [];
21
+ const startedServers: Server[] = [];
22
+
23
+ /** DevRunner 응답을 대신하는 실제 로컬 HTTP 서버를 띄웁니다. */
24
+ async function startDevRunnerServer(): Promise<number> {
25
+ const server = createServer((request, response) => {
26
+ response.setHeader("content-type", "application/json");
27
+ if (request.url === "/__test__/status") {
28
+ response.end(JSON.stringify(STATUS_PAYLOAD));
29
+ return;
30
+ }
31
+ if (request.url === "/__test__/run") {
32
+ response.end(JSON.stringify(RUN_RESULT));
33
+ return;
34
+ }
35
+ response.statusCode = 404;
36
+ response.end("{}");
37
+ });
38
+ startedServers.push(server);
39
+ await new Promise<void>((resolve) => {
40
+ server.listen(0, "127.0.0.1", resolve);
41
+ });
42
+ // SAFETY: TCP listen 이후의 주소는 항상 AddressInfo 입니다.
43
+ const address = server.address() as AddressInfo;
44
+ return address.port;
45
+ }
46
+
47
+ /** dist 빌드 없이 src/sonamu.config.ts만 가진 일반 개발 프로젝트를 만듭니다. */
48
+ async function createSourceOnlyProject(port: number | null): Promise<string> {
49
+ const projectRoot = await mkdtemp(path.join(os.tmpdir(), "sonamu-test-run-client-"));
50
+ temporaryRoots.push(projectRoot);
51
+ await mkdir(path.join(projectRoot, "src"), { recursive: true });
52
+ await writeFile(path.join(projectRoot, ".env"), "SONAMU_TEST_RUN_CLIENT=1\n");
53
+ if (port === null) return projectRoot;
54
+
55
+ await writeFile(
56
+ path.join(projectRoot, "src", "sonamu.config.ts"),
57
+ `
58
+ export default {
59
+ server: { listen: { host: "127.0.0.1", port: ${port} } },
60
+ test: { devRunner: { enabled: true, routePrefix: "/__test__" } },
61
+ };
62
+ `,
63
+ );
64
+ return projectRoot;
65
+ }
66
+
67
+ function enterPlainDevShell(): void {
68
+ // dist 설정을 고르는 환경변수가 없는 일반 개발 셸을 재현합니다.
69
+ delete process.env.VITEST;
70
+ delete process.env.HOT;
71
+ }
72
+
73
+ afterEach(async () => {
74
+ if (originalVitest === undefined) delete process.env.VITEST;
75
+ else process.env.VITEST = originalVitest;
76
+ if (originalHot === undefined) delete process.env.HOT;
77
+ else process.env.HOT = originalHot;
78
+
79
+ await Promise.all(
80
+ startedServers.splice(0).map(
81
+ (server) =>
82
+ new Promise<void>((resolve) => {
83
+ server.close(() => resolve());
84
+ }),
85
+ ),
86
+ );
87
+ await Promise.all(
88
+ temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
89
+ );
90
+ });
91
+
92
+ describe("test-run-client의 sonamu.config 로딩", () => {
93
+ it("dist 빌드가 없어도 status가 src/sonamu.config.ts를 읽는다", async () => {
94
+ const port = await startDevRunnerServer();
95
+ const projectRoot = await createSourceOnlyProject(port);
96
+ enterPlainDevShell();
97
+
98
+ await expect(createDefaultTestRunClient(projectRoot).status()).resolves.toEqual(STATUS_PAYLOAD);
99
+ expect(process.env.VITEST).toBeUndefined();
100
+ });
101
+
102
+ it("dist 빌드가 없어도 run이 src/sonamu.config.ts를 읽는다", async () => {
103
+ const port = await startDevRunnerServer();
104
+ const projectRoot = await createSourceOnlyProject(port);
105
+ enterPlainDevShell();
106
+
107
+ await expect(
108
+ createDefaultTestRunClient(projectRoot).run({ files: ["a.test.ts"] }),
109
+ ).resolves.toMatchObject({ ok: true });
110
+ expect(process.env.VITEST).toBeUndefined();
111
+ });
112
+
113
+ it("기존 VITEST 값은 설정 로딩 후 원래 값으로 되돌린다", async () => {
114
+ const port = await startDevRunnerServer();
115
+ const projectRoot = await createSourceOnlyProject(port);
116
+ enterPlainDevShell();
117
+ process.env.VITEST = "false";
118
+
119
+ await expect(createDefaultTestRunClient(projectRoot).status()).resolves.toEqual(STATUS_PAYLOAD);
120
+ expect(process.env.VITEST).toBe("false");
121
+ });
122
+
123
+ it("설정 파일이 없으면 TEST_RUN_CONFIG_ERROR로 실패하고 환경변수를 복원한다", async () => {
124
+ const projectRoot = await createSourceOnlyProject(null);
125
+ enterPlainDevShell();
126
+
127
+ await expect(createDefaultTestRunClient(projectRoot).status()).rejects.toMatchObject({
128
+ code: "TEST_RUN_CONFIG_ERROR",
129
+ });
130
+ expect(process.env.VITEST).toBeUndefined();
131
+ });
132
+
133
+ it("devRunner가 비활성화되어 있으면 설정 오류로 실패한다", async () => {
134
+ const projectRoot = await mkdtemp(path.join(os.tmpdir(), "sonamu-test-run-client-off-"));
135
+ temporaryRoots.push(projectRoot);
136
+ await mkdir(path.join(projectRoot, "src"), { recursive: true });
137
+ await writeFile(path.join(projectRoot, ".env"), "SONAMU_TEST_RUN_CLIENT=1\n");
138
+ await writeFile(
139
+ path.join(projectRoot, "src", "sonamu.config.ts"),
140
+ `
141
+ export default {
142
+ server: { listen: { host: "127.0.0.1", port: 3000 } },
143
+ test: { devRunner: { enabled: false } },
144
+ };
145
+ `,
146
+ );
147
+ enterPlainDevShell();
148
+
149
+ await expect(createDefaultTestRunClient(projectRoot).status()).rejects.toMatchObject({
150
+ code: "TEST_RUN_CONFIG_ERROR",
151
+ });
152
+ expect(process.env.VITEST).toBeUndefined();
153
+ });
154
+ });
@@ -0,0 +1,256 @@
1
+ import { constants } from "node:fs";
2
+ import * as defaultFs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ type CddFileSystem = Pick<typeof defaultFs, "lstat" | "mkdir" | "open" | "opendir" | "realpath">;
6
+
7
+ function invalidCddPath(relativePath: string, cause?: unknown): Error {
8
+ return Object.assign(new Error(`CDD 경로가 contract 밖을 참조합니다: ${relativePath}`), {
9
+ code: "INVALID_CDD_PATH",
10
+ cause,
11
+ });
12
+ }
13
+
14
+ function isWithin(root: string, target: string): boolean {
15
+ const relative = path.relative(root, target);
16
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
17
+ }
18
+
19
+ async function nearestExistingPath(fs: CddFileSystem, target: string): Promise<string> {
20
+ let candidate = target;
21
+ while (true) {
22
+ try {
23
+ await fs.lstat(candidate);
24
+ return candidate;
25
+ } catch (error) {
26
+ // SAFETY: node:fs lstat 실패의 code만 읽어 경로 부재를 구분합니다.
27
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
28
+ const parent = path.dirname(candidate);
29
+ if (parent === candidate) throw error;
30
+ candidate = parent;
31
+ }
32
+ }
33
+ }
34
+
35
+ function createCddFileOperation({
36
+ contractRoot,
37
+ fs = defaultFs,
38
+ }: {
39
+ contractRoot: string;
40
+ fs?: CddFileSystem;
41
+ }) {
42
+ async function resolveBoundary(relativePath: string) {
43
+ const [canonicalRoot, configuredRoot] = await Promise.all([
44
+ fs.realpath(contractRoot),
45
+ Promise.resolve(path.resolve(contractRoot)),
46
+ ]);
47
+ const target = path.resolve(configuredRoot, relativePath);
48
+ if (!isWithin(configuredRoot, target)) throw invalidCddPath(relativePath);
49
+ return { canonicalRoot, target };
50
+ }
51
+
52
+ async function assertCanonicalPath(
53
+ canonicalRoot: string,
54
+ target: string,
55
+ relativePath: string,
56
+ ): Promise<string> {
57
+ const canonicalTarget = await fs.realpath(target);
58
+ if (!isWithin(canonicalRoot, canonicalTarget)) throw invalidCddPath(relativePath);
59
+ return canonicalTarget;
60
+ }
61
+
62
+ return {
63
+ async readDirectory(relativePath: string) {
64
+ const { canonicalRoot, target } = await resolveBoundary(relativePath);
65
+ const canonicalTarget = await assertCanonicalPath(canonicalRoot, target, relativePath);
66
+ let directory;
67
+ try {
68
+ directory = await fs.opendir(target);
69
+ const recheckedTarget = await assertCanonicalPath(canonicalRoot, target, relativePath);
70
+ if (recheckedTarget !== canonicalTarget) throw invalidCddPath(relativePath);
71
+ const entries = [];
72
+ while (true) {
73
+ const entry = await directory.read();
74
+ if (entry === null) return entries;
75
+ entries.push(entry);
76
+ }
77
+ } finally {
78
+ await directory?.close();
79
+ }
80
+ },
81
+ async read(relativePath: string): Promise<string> {
82
+ const { canonicalRoot, target } = await resolveBoundary(relativePath);
83
+ const canonicalTarget = await assertCanonicalPath(canonicalRoot, target, relativePath);
84
+ let handle;
85
+ try {
86
+ // 최종 symlink를 따라가지 않은 descriptor를 연 뒤 실제 경로를 다시 확인합니다.
87
+ handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
88
+ const recheckedTarget = await assertCanonicalPath(canonicalRoot, target, relativePath);
89
+ if (recheckedTarget !== canonicalTarget) throw invalidCddPath(relativePath);
90
+ return await handle.readFile("utf8");
91
+ } catch (error) {
92
+ // SAFETY: node:fs open 실패의 code만 읽어 symlink 거부를 정규화합니다.
93
+ if ((error as NodeJS.ErrnoException).code === "ELOOP") {
94
+ throw invalidCddPath(relativePath, error);
95
+ }
96
+ throw error;
97
+ } finally {
98
+ await handle?.close();
99
+ }
100
+ },
101
+
102
+ async write(relativePath: string, contents: string): Promise<void> {
103
+ const { canonicalRoot, target } = await resolveBoundary(relativePath);
104
+ const existing = await nearestExistingPath(fs, target);
105
+ const canonicalExisting = await assertCanonicalPath(canonicalRoot, existing, relativePath);
106
+ const targetExists = existing === target;
107
+
108
+ if (!targetExists) {
109
+ await fs.mkdir(path.dirname(target), { recursive: true });
110
+ await assertCanonicalPath(canonicalRoot, path.dirname(target), relativePath);
111
+ }
112
+
113
+ let handle;
114
+ try {
115
+ // 검증 전 truncate를 피하고 새 파일은 배타적으로 생성합니다.
116
+ const flags = targetExists
117
+ ? constants.O_WRONLY | constants.O_NOFOLLOW
118
+ : constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
119
+ handle = await fs.open(target, flags, 0o666);
120
+ const canonicalTarget = await assertCanonicalPath(canonicalRoot, target, relativePath);
121
+ if (
122
+ !isWithin(canonicalRoot, canonicalTarget) ||
123
+ (targetExists && canonicalTarget !== canonicalExisting)
124
+ ) {
125
+ throw invalidCddPath(relativePath);
126
+ }
127
+ await handle.truncate(0);
128
+ await handle.writeFile(contents, "utf8");
129
+ } catch (error) {
130
+ // SAFETY: node:fs open 실패의 code만 읽어 symlink 거부를 정규화합니다.
131
+ if ((error as NodeJS.ErrnoException).code === "ELOOP") {
132
+ throw invalidCddPath(relativePath, error);
133
+ }
134
+ throw error;
135
+ } finally {
136
+ await handle?.close();
137
+ }
138
+ },
139
+ };
140
+ }
141
+
142
+ type CddRuleDocument = {
143
+ description?: string;
144
+ rules?: Array<{ id: string; when: string; instruction: string; examples?: string[] }>;
145
+ };
146
+
147
+ function parseCddRules(contents: string): CddRuleDocument {
148
+ try {
149
+ // SAFETY: CDD 규칙 소비 지점은 선택 필드만 읽고 배열 부재를 기본값으로 처리합니다.
150
+ return JSON.parse(contents) as CddRuleDocument;
151
+ } catch (cause) {
152
+ throw Object.assign(new Error("CDD rules JSON is malformed"), {
153
+ code: "INVALID_CDD_RULES",
154
+ exitCode: 2,
155
+ cause,
156
+ });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * 규칙 문서를 읽되, 아직 파일이 없으면 빈 문서로 취급합니다.
162
+ *
163
+ * 첫 규칙 등록 시 rules 디렉터리와 파일이 없는 상태를 부트스트랩하기 위한 처리이며,
164
+ * 깨진 JSON은 그대로 INVALID_CDD_RULES로 실패시켜 덮어쓰기를 막습니다.
165
+ */
166
+ async function readCddRuleDocument(
167
+ files: { read(relativePath: string): Promise<string> },
168
+ relativePath: string,
169
+ ): Promise<CddRuleDocument> {
170
+ try {
171
+ return parseCddRules(await files.read(relativePath));
172
+ } catch (error) {
173
+ // SAFETY: node:fs 읽기 실패의 code만 읽어 규칙 문서 부재를 구분합니다.
174
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ export function createDefaultCddToolingAdapter({
180
+ contractRoot,
181
+ fs = defaultFs,
182
+ }: {
183
+ contractRoot: string;
184
+ fs?: CddFileSystem;
185
+ }) {
186
+ const files = createCddFileOperation({ contractRoot, fs });
187
+
188
+ return {
189
+ async read(input: { path: string }) {
190
+ return { content: await files.read(input.path), fileType: "contract" as const };
191
+ },
192
+ async rules() {
193
+ let entries;
194
+ try {
195
+ entries = await files.readDirectory("rules");
196
+ } catch (error) {
197
+ // SAFETY: node:fs 디렉터리 조회 실패의 code만 읽어 규칙 디렉터리 부재를 구분합니다.
198
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { rules: [] };
199
+ throw error;
200
+ }
201
+
202
+ const rules = [];
203
+ for (const entry of entries) {
204
+ if (!entry.isFile() || !entry.name.endsWith(".rules.json")) continue;
205
+ const key = entry.name.replace(/\.rules\.json$/, "");
206
+ const relativePath = `rules/${entry.name}`;
207
+ const document = parseCddRules(await files.read(relativePath));
208
+ rules.push({
209
+ key,
210
+ path: relativePath,
211
+ description: document.description ?? "",
212
+ ruleCount: document.rules?.length ?? 0,
213
+ });
214
+ }
215
+ return { rules };
216
+ },
217
+ async showRule(input: { ruleId: string }) {
218
+ const relativePath = `rules/${input.ruleId}.rules.json`;
219
+ return {
220
+ key: input.ruleId,
221
+ path: relativePath,
222
+ ...parseCddRules(await files.read(relativePath)),
223
+ };
224
+ },
225
+ async addRule(input: {
226
+ ruleKey: string;
227
+ id: string;
228
+ when: string;
229
+ text: string;
230
+ examples?: string[];
231
+ dryRun: boolean;
232
+ }) {
233
+ const rule = { ruleKey: input.ruleKey, id: input.id, when: input.when, text: input.text };
234
+ if (input.dryRun) return { dryRun: true as const, rule };
235
+
236
+ const relativePath = `rules/${input.ruleKey}.rules.json`;
237
+ const document = await readCddRuleDocument(files, relativePath);
238
+ document.rules ??= [];
239
+ document.rules.push({
240
+ id: input.id,
241
+ when: input.when,
242
+ instruction: input.text,
243
+ examples: input.examples ?? [],
244
+ });
245
+ await files.write(relativePath, `${JSON.stringify(document, null, 2)}\n`);
246
+ return { key: input.ruleKey, path: relativePath, ...document };
247
+ },
248
+ async addAcceptanceCriterion(input: { document: string; text: string; dryRun: boolean }) {
249
+ if (input.dryRun)
250
+ return { operation: "addAcceptanceCriterion" as const, input, dryRun: true };
251
+ const contents = await files.read(input.document);
252
+ await files.write(input.document, `${contents}\n- ${input.text}\n`);
253
+ return { document: input.document, text: input.text, applied: true as const };
254
+ },
255
+ };
256
+ }