@tbsten/mir-core 0.0.1-alpha02

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 (31) hide show
  1. package/package.json +25 -0
  2. package/src/__tests__/errors.test.ts +71 -0
  3. package/src/__tests__/hooks.test.ts +145 -0
  4. package/src/__tests__/i18n.test.ts +60 -0
  5. package/src/__tests__/remote-registry.test.ts +265 -0
  6. package/src/__tests__/safe-yaml-parser.test.ts +187 -0
  7. package/src/__tests__/snapshots/__snapshots__/error-messages.snapshot.test.ts.snap +27 -0
  8. package/src/__tests__/snapshots/__snapshots__/snippet-schema-output.snapshot.test.ts.snap +78 -0
  9. package/src/__tests__/snapshots/__snapshots__/template-output.snapshot.test.ts.snap +45 -0
  10. package/src/__tests__/snapshots/error-messages.snapshot.test.ts +76 -0
  11. package/src/__tests__/snapshots/snippet-schema-output.snapshot.test.ts +98 -0
  12. package/src/__tests__/snapshots/template-output.snapshot.test.ts +73 -0
  13. package/src/__tests__/snippet-schema.test.ts +180 -0
  14. package/src/__tests__/symlink-checker.test.ts +95 -0
  15. package/src/__tests__/template-engine.test.ts +137 -0
  16. package/src/__tests__/validate-name.test.ts +61 -0
  17. package/src/errors.ts +82 -0
  18. package/src/hooks.ts +63 -0
  19. package/src/i18n/index.ts +32 -0
  20. package/src/i18n/locales/en.ts +91 -0
  21. package/src/i18n/locales/ja.ts +91 -0
  22. package/src/i18n/types.ts +91 -0
  23. package/src/index.ts +84 -0
  24. package/src/lib/symlink-checker.ts +62 -0
  25. package/src/registry.ts +117 -0
  26. package/src/remote-registry.ts +260 -0
  27. package/src/safe-yaml-parser.ts +71 -0
  28. package/src/snippet-schema.ts +117 -0
  29. package/src/template-engine.ts +114 -0
  30. package/src/validate-name.ts +12 -0
  31. package/tsconfig.json +17 -0
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@tbsten/mir-core",
3
+ "version": "0.0.1-alpha02",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "publishConfig": {
9
+ "registry": "https://registry.npmjs.org/",
10
+ "access": "public"
11
+ },
12
+ "dependencies": {
13
+ "handlebars": "^4.7.8",
14
+ "js-yaml": "^4.1.1"
15
+ },
16
+ "scripts": {
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "devDependencies": {
21
+ "@types/js-yaml": "^4.0.9",
22
+ "typescript": "^5",
23
+ "vitest": "^3"
24
+ }
25
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * mir-core: エラークラスの unit テスト
3
+ */
4
+ import { describe, it, expect, beforeEach } from "vitest";
5
+ import {
6
+ MirError,
7
+ ValidationError,
8
+ SnippetNotFoundError,
9
+ SnippetAlreadyExistsError,
10
+ RegistryNotFoundError,
11
+ RegistryRemoteError,
12
+ PathTraversalError,
13
+ FileConflictError,
14
+ setLocale,
15
+ } from "../index.js";
16
+
17
+ // TODO: 現時点では理想の挙動をテストケースとして記述。後で有効化する。
18
+
19
+ describe("エラークラス", () => {
20
+ beforeEach(() => {
21
+ setLocale("ja");
22
+ });
23
+
24
+ it("MirError は Error を継承", () => {
25
+ const err = new MirError("test");
26
+ expect(err).toBeInstanceOf(Error);
27
+ expect(err.name).toBe("MirError");
28
+ });
29
+
30
+ it("ValidationError は MirError を継承", () => {
31
+ const err = new ValidationError("test");
32
+ expect(err).toBeInstanceOf(MirError);
33
+ expect(err.name).toBe("ValidationError");
34
+ });
35
+
36
+ it("SnippetNotFoundError のメッセージに名前が含まれる", () => {
37
+ const err = new SnippetNotFoundError("react-hook");
38
+ expect(err.message).toContain("react-hook");
39
+ expect(err.name).toBe("SnippetNotFoundError");
40
+ });
41
+
42
+ it("SnippetAlreadyExistsError のメッセージに名前が含まれる", () => {
43
+ const err = new SnippetAlreadyExistsError("react-hook");
44
+ expect(err.message).toContain("react-hook");
45
+ });
46
+
47
+ it("RegistryNotFoundError のメッセージに名前が含まれる", () => {
48
+ const err = new RegistryNotFoundError("my-registry");
49
+ expect(err.message).toContain("my-registry");
50
+ });
51
+
52
+ it("RegistryRemoteError (名前あり)", () => {
53
+ const err = new RegistryRemoteError("remote-reg");
54
+ expect(err.message).toContain("remote-reg");
55
+ });
56
+
57
+ it("RegistryRemoteError (名前なし)", () => {
58
+ const err = new RegistryRemoteError();
59
+ expect(err.message).not.toBe("");
60
+ });
61
+
62
+ it("PathTraversalError のメッセージにパスが含まれる", () => {
63
+ const err = new PathTraversalError("../etc/passwd");
64
+ expect(err.message).toContain("../etc/passwd");
65
+ });
66
+
67
+ it("FileConflictError のメッセージにパスが含まれる", () => {
68
+ const err = new FileConflictError("index.ts");
69
+ expect(err.message).toContain("index.ts");
70
+ });
71
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * mir-core: hooks エンジンの unit テスト
3
+ */
4
+ import { describe, it, expect, vi } from "vitest";
5
+ import { executeHooks, ExitHookError, MirError } from "../index.js";
6
+ import type { Action } from "../index.js";
7
+
8
+ // TODO: 現時点では理想の挙動をテストケースとして記述。後で有効化する。
9
+
10
+ describe("executeHooks", () => {
11
+ describe("echo アクション", () => {
12
+ it("onEcho コールバックが呼ばれる", () => {
13
+ const onEcho = vi.fn();
14
+ const actions: Action[] = [{ echo: "hello" }];
15
+ executeHooks(actions, {}, { onEcho });
16
+ expect(onEcho).toHaveBeenCalledWith("hello");
17
+ });
18
+
19
+ it("echo メッセージ内の変数が展開される", () => {
20
+ const onEcho = vi.fn();
21
+ const actions: Action[] = [{ echo: "Hello, {{ name }}!" }];
22
+ executeHooks(actions, { name: "World" }, { onEcho });
23
+ expect(onEcho).toHaveBeenCalledWith("Hello, World!");
24
+ });
25
+
26
+ it("onEcho が未設定でもエラーにならない", () => {
27
+ const actions: Action[] = [{ echo: "hello" }];
28
+ expect(() => executeHooks(actions, {})).not.toThrow();
29
+ });
30
+ });
31
+
32
+ describe("exit アクション", () => {
33
+ it("exit: true で ExitHookError を throw", () => {
34
+ const actions: Action[] = [{ exit: true }];
35
+ expect(() => executeHooks(actions, {})).toThrow(ExitHookError);
36
+ });
37
+
38
+ it("exit: false では throw しない", () => {
39
+ const actions: Action[] = [{ exit: false }];
40
+ expect(() => executeHooks(actions, {})).not.toThrow();
41
+ });
42
+
43
+ it("if 条件が truthy の場合に exit", () => {
44
+ const actions: Action[] = [{ exit: true, if: "{{ flag }}" }];
45
+ expect(() => executeHooks(actions, { flag: "yes" })).toThrow(
46
+ ExitHookError,
47
+ );
48
+ });
49
+
50
+ it("if 条件が falsy (空文字) の場合は exit しない", () => {
51
+ const actions: Action[] = [{ exit: true, if: "{{ flag }}" }];
52
+ expect(() => executeHooks(actions, { flag: "" })).not.toThrow();
53
+ });
54
+
55
+ it("if 条件が 'false' の場合は exit しない", () => {
56
+ const actions: Action[] = [{ exit: true, if: "{{ flag }}" }];
57
+ expect(() => executeHooks(actions, { flag: "false" })).not.toThrow();
58
+ });
59
+
60
+ it("if 条件が '0' の場合は exit しない", () => {
61
+ const actions: Action[] = [{ exit: true, if: "{{ flag }}" }];
62
+ expect(() => executeHooks(actions, { flag: "0" })).not.toThrow();
63
+ });
64
+ });
65
+
66
+ describe("input アクション", () => {
67
+ it("default 値がある場合は変数に設定される", () => {
68
+ const actions: Action[] = [
69
+ {
70
+ input: {
71
+ q: {
72
+ "answer-to": "answer",
73
+ schema: { default: "yes" },
74
+ },
75
+ },
76
+ },
77
+ ];
78
+ const result = executeHooks(actions, {});
79
+ expect(result.answer).toBe("yes");
80
+ });
81
+
82
+ it("default がなく answer-to がある場合はエラー", () => {
83
+ const actions: Action[] = [
84
+ {
85
+ input: {
86
+ q: { "answer-to": "answer" },
87
+ },
88
+ },
89
+ ];
90
+ expect(() => executeHooks(actions, {})).toThrow(MirError);
91
+ });
92
+
93
+ it("answer-to がない場合はスキップ", () => {
94
+ const actions: Action[] = [
95
+ {
96
+ input: { q: { description: "test" } },
97
+ },
98
+ ];
99
+ expect(() => executeHooks(actions, {})).not.toThrow();
100
+ });
101
+ });
102
+
103
+ describe("複合アクション", () => {
104
+ it("複数のアクションが順番に実行される", () => {
105
+ const onEcho = vi.fn();
106
+ const actions: Action[] = [
107
+ { echo: "step1" },
108
+ { echo: "step2" },
109
+ { echo: "step3" },
110
+ ];
111
+ executeHooks(actions, {}, { onEcho });
112
+ expect(onEcho).toHaveBeenCalledTimes(3);
113
+ expect(onEcho.mock.calls[0][0]).toBe("step1");
114
+ expect(onEcho.mock.calls[1][0]).toBe("step2");
115
+ expect(onEcho.mock.calls[2][0]).toBe("step3");
116
+ });
117
+
118
+ it("exit が途中で発生すると後続アクションは実行されない", () => {
119
+ const onEcho = vi.fn();
120
+ const actions: Action[] = [
121
+ { echo: "before" },
122
+ { exit: true },
123
+ { echo: "after" },
124
+ ];
125
+ expect(() => executeHooks(actions, {}, { onEcho })).toThrow(
126
+ ExitHookError,
127
+ );
128
+ expect(onEcho).toHaveBeenCalledTimes(1);
129
+ expect(onEcho).toHaveBeenCalledWith("before");
130
+ });
131
+
132
+ it("元の変数オブジェクトは変更されない (immutable)", () => {
133
+ const original = { key: "original" };
134
+ const actions: Action[] = [
135
+ {
136
+ input: {
137
+ q: { "answer-to": "newKey", schema: { default: "value" } },
138
+ },
139
+ },
140
+ ];
141
+ executeHooks(actions, original);
142
+ expect(original).not.toHaveProperty("newKey");
143
+ });
144
+ });
145
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * mir-core: i18n の unit テスト
3
+ */
4
+ import { describe, it, expect, beforeEach } from "vitest";
5
+ import { setLocale, getLocale, t } from "../index.js";
6
+ import type { Locale } from "../index.js";
7
+
8
+ // TODO: 現時点では理想の挙動をテストケースとして記述。後で有効化する。
9
+
10
+ describe("i18n", () => {
11
+ beforeEach(() => {
12
+ setLocale("ja");
13
+ });
14
+
15
+ describe("setLocale / getLocale", () => {
16
+ it("デフォルトは ja", () => {
17
+ expect(getLocale()).toBe("ja");
18
+ });
19
+
20
+ it("en に切り替え可能", () => {
21
+ setLocale("en");
22
+ expect(getLocale()).toBe("en");
23
+ });
24
+
25
+ it("ja に戻せる", () => {
26
+ setLocale("en");
27
+ setLocale("ja");
28
+ expect(getLocale()).toBe("ja");
29
+ });
30
+ });
31
+
32
+ describe("t (メッセージ取得)", () => {
33
+ it("日本語メッセージを取得する", () => {
34
+ setLocale("ja");
35
+ const msg = t("error.snippet-not-found", { name: "test" });
36
+ expect(msg).toContain("test");
37
+ expect(msg).not.toBe("");
38
+ });
39
+
40
+ it("英語メッセージを取得する", () => {
41
+ setLocale("en");
42
+ const msg = t("error.snippet-not-found", { name: "test" });
43
+ expect(msg).toContain("test");
44
+ expect(msg).not.toBe("");
45
+ });
46
+
47
+ it("日英でメッセージが異なる", () => {
48
+ setLocale("ja");
49
+ const ja = t("error.snippet-not-found", { name: "x" });
50
+ setLocale("en");
51
+ const en = t("error.snippet-not-found", { name: "x" });
52
+ expect(ja).not.toBe(en);
53
+ });
54
+
55
+ it("パラメータが展開される", () => {
56
+ const msg = t("error.invalid-snippet-name", { name: "bad_name" });
57
+ expect(msg).toContain("bad_name");
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ fetchRegistryManifest,
4
+ listRemoteSnippets,
5
+ fetchSnippetDefinition,
6
+ fetchRemoteFiles,
7
+ fetchRemoteSnippet,
8
+ expandRemoteTemplateFiles,
9
+ clearAllRemoteRegistryCaches,
10
+ } from "../remote-registry.js";
11
+ import { RemoteRegistryFetchError, InvalidManifestError } from "../errors.js";
12
+
13
+ const BASE_URL = "https://example.com/registry";
14
+
15
+ const validManifest = {
16
+ snippets: {
17
+ "react-hook": { files: ["{{ name }}.ts", "{{ name }}.test.ts"] },
18
+ "express-api": { files: ["index.ts"] },
19
+ },
20
+ };
21
+
22
+ const reactHookYaml = `
23
+ name: react-hook
24
+ description: React カスタムフック雛形
25
+ variables:
26
+ name:
27
+ description: フック名
28
+ schema:
29
+ type: string
30
+ `;
31
+
32
+ function mockFetch(handlers: Record<string, { status: number; body: string }>) {
33
+ return vi.fn(async (url: string) => {
34
+ const handler = handlers[url];
35
+ if (!handler) {
36
+ return { ok: false, status: 404, json: async () => ({}), text: async () => "" };
37
+ }
38
+ return {
39
+ ok: handler.status >= 200 && handler.status < 300,
40
+ status: handler.status,
41
+ json: async () => JSON.parse(handler.body),
42
+ text: async () => handler.body,
43
+ };
44
+ }) as unknown as typeof globalThis.fetch;
45
+ }
46
+
47
+ describe("fetchRegistryManifest", () => {
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ clearAllRemoteRegistryCaches();
51
+ });
52
+
53
+ it("正常なマニフェストを取得できる", async () => {
54
+ vi.stubGlobal(
55
+ "fetch",
56
+ mockFetch({
57
+ [`${BASE_URL}/index.json`]: {
58
+ status: 200,
59
+ body: JSON.stringify(validManifest),
60
+ },
61
+ }),
62
+ );
63
+ const manifest = await fetchRegistryManifest(BASE_URL);
64
+ expect(manifest.snippets).toHaveProperty("react-hook");
65
+ expect(manifest.snippets["react-hook"].files).toHaveLength(2);
66
+ });
67
+
68
+ it("末尾スラッシュ付き URL でも取得できる", async () => {
69
+ vi.stubGlobal(
70
+ "fetch",
71
+ mockFetch({
72
+ [`${BASE_URL}/index.json`]: {
73
+ status: 200,
74
+ body: JSON.stringify(validManifest),
75
+ },
76
+ }),
77
+ );
78
+ const manifest = await fetchRegistryManifest(`${BASE_URL}/`);
79
+ expect(manifest.snippets).toHaveProperty("react-hook");
80
+ });
81
+
82
+ it("HTTP エラーで RemoteRegistryFetchError", async () => {
83
+ vi.stubGlobal(
84
+ "fetch",
85
+ mockFetch({
86
+ [`${BASE_URL}/index.json`]: { status: 500, body: "error" },
87
+ }),
88
+ );
89
+ await expect(fetchRegistryManifest(BASE_URL)).rejects.toThrow(
90
+ RemoteRegistryFetchError,
91
+ );
92
+ });
93
+
94
+ it("ネットワークエラーで RemoteRegistryFetchError", async () => {
95
+ vi.stubGlobal(
96
+ "fetch",
97
+ vi.fn(async () => {
98
+ throw new TypeError("fetch failed");
99
+ }) as unknown as typeof globalThis.fetch,
100
+ );
101
+ await expect(fetchRegistryManifest(BASE_URL)).rejects.toThrow(
102
+ RemoteRegistryFetchError,
103
+ );
104
+ });
105
+
106
+ it("不正なマニフェストで InvalidManifestError", async () => {
107
+ vi.stubGlobal(
108
+ "fetch",
109
+ mockFetch({
110
+ [`${BASE_URL}/index.json`]: {
111
+ status: 200,
112
+ body: JSON.stringify({ invalid: true }),
113
+ },
114
+ }),
115
+ );
116
+ await expect(fetchRegistryManifest(BASE_URL)).rejects.toThrow(
117
+ InvalidManifestError,
118
+ );
119
+ });
120
+ });
121
+
122
+ describe("listRemoteSnippets", () => {
123
+ afterEach(() => {
124
+ vi.restoreAllMocks();
125
+ });
126
+
127
+ it("snippet 名一覧を返す", async () => {
128
+ vi.stubGlobal(
129
+ "fetch",
130
+ mockFetch({
131
+ [`${BASE_URL}/index.json`]: {
132
+ status: 200,
133
+ body: JSON.stringify(validManifest),
134
+ },
135
+ }),
136
+ );
137
+ const names = await listRemoteSnippets(BASE_URL);
138
+ expect(names).toEqual(["react-hook", "express-api"]);
139
+ });
140
+ });
141
+
142
+ describe("fetchSnippetDefinition", () => {
143
+ afterEach(() => {
144
+ vi.restoreAllMocks();
145
+ });
146
+
147
+ it("YAML を parse して SnippetDefinition を返す", async () => {
148
+ vi.stubGlobal(
149
+ "fetch",
150
+ mockFetch({
151
+ [`${BASE_URL}/react-hook.yaml`]: {
152
+ status: 200,
153
+ body: reactHookYaml,
154
+ },
155
+ }),
156
+ );
157
+ const def = await fetchSnippetDefinition(BASE_URL, "react-hook");
158
+ expect(def.name).toBe("react-hook");
159
+ expect(def.variables).toHaveProperty("name");
160
+ });
161
+
162
+ it("404 で RemoteRegistryFetchError", async () => {
163
+ vi.stubGlobal("fetch", mockFetch({}));
164
+ await expect(
165
+ fetchSnippetDefinition(BASE_URL, "nonexistent"),
166
+ ).rejects.toThrow(RemoteRegistryFetchError);
167
+ });
168
+ });
169
+
170
+ describe("fetchRemoteFiles", () => {
171
+ afterEach(() => {
172
+ vi.restoreAllMocks();
173
+ });
174
+
175
+ it("テンプレートファイルを並列取得する", async () => {
176
+ vi.stubGlobal(
177
+ "fetch",
178
+ mockFetch({
179
+ [`${BASE_URL}/react-hook/${encodeURIComponent("{{ name }}.ts")}`]: {
180
+ status: 200,
181
+ body: "export function {{ name }}() {}",
182
+ },
183
+ [`${BASE_URL}/react-hook/${encodeURIComponent("{{ name }}.test.ts")}`]:
184
+ {
185
+ status: 200,
186
+ body: 'test("{{ name }}", () => {});',
187
+ },
188
+ }),
189
+ );
190
+ const files = await fetchRemoteFiles(BASE_URL, "react-hook", [
191
+ "{{ name }}.ts",
192
+ "{{ name }}.test.ts",
193
+ ]);
194
+ expect(files.size).toBe(2);
195
+ expect(files.get("{{ name }}.ts")).toContain("{{ name }}");
196
+ });
197
+ });
198
+
199
+ describe("fetchRemoteSnippet", () => {
200
+ afterEach(() => {
201
+ vi.restoreAllMocks();
202
+ });
203
+
204
+ it("definition + files を統合して返す", async () => {
205
+ vi.stubGlobal(
206
+ "fetch",
207
+ mockFetch({
208
+ [`${BASE_URL}/index.json`]: {
209
+ status: 200,
210
+ body: JSON.stringify(validManifest),
211
+ },
212
+ [`${BASE_URL}/react-hook.yaml`]: {
213
+ status: 200,
214
+ body: reactHookYaml,
215
+ },
216
+ [`${BASE_URL}/react-hook/${encodeURIComponent("{{ name }}.ts")}`]: {
217
+ status: 200,
218
+ body: "export function {{ name }}() {}",
219
+ },
220
+ [`${BASE_URL}/react-hook/${encodeURIComponent("{{ name }}.test.ts")}`]:
221
+ {
222
+ status: 200,
223
+ body: 'test("{{ name }}", () => {});',
224
+ },
225
+ }),
226
+ );
227
+ const snippet = await fetchRemoteSnippet(BASE_URL, "react-hook");
228
+ expect(snippet.definition.name).toBe("react-hook");
229
+ expect(snippet.files.size).toBe(2);
230
+ });
231
+
232
+ it("マニフェストに存在しない snippet は 404 エラー", async () => {
233
+ vi.stubGlobal(
234
+ "fetch",
235
+ mockFetch({
236
+ [`${BASE_URL}/index.json`]: {
237
+ status: 200,
238
+ body: JSON.stringify(validManifest),
239
+ },
240
+ }),
241
+ );
242
+ await expect(
243
+ fetchRemoteSnippet(BASE_URL, "nonexistent"),
244
+ ).rejects.toThrow(RemoteRegistryFetchError);
245
+ });
246
+ });
247
+
248
+ describe("expandRemoteTemplateFiles", () => {
249
+ it("ファイルパスと内容の変数を展開する", () => {
250
+ const files = new Map([
251
+ ["{{ name }}.ts", "export function {{ name }}() {}"],
252
+ ["{{ name }}.test.ts", 'test("{{ name }}", () => {});'],
253
+ ]);
254
+ const expanded = expandRemoteTemplateFiles(files, { name: "useAuth" });
255
+ expect(expanded.has("useAuth.ts")).toBe(true);
256
+ expect(expanded.get("useAuth.ts")).toBe("export function useAuth() {}");
257
+ expect(expanded.has("useAuth.test.ts")).toBe(true);
258
+ });
259
+
260
+ it("変数が空の場合もそのまま展開する", () => {
261
+ const files = new Map([["index.ts", "console.log('hello');"]]);
262
+ const expanded = expandRemoteTemplateFiles(files, {});
263
+ expect(expanded.get("index.ts")).toBe("console.log('hello');");
264
+ });
265
+ });