@akanjs/devkit 2.3.9-rc.0 → 2.3.9-rc.10

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.
@@ -0,0 +1,296 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { AkanModuleContext } from "../akanContext";
6
+ import type { Workspace } from "../commandDecorators";
7
+ import { buildAkanModuleContextIndex } from "./moduleIndex";
8
+
9
+ const tempRoots: string[] = [];
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
13
+ });
14
+
15
+ const makeWorkspace = async () => {
16
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-module-index-"));
17
+ tempRoots.push(root);
18
+ return {
19
+ root,
20
+ workspace: { workspaceRoot: root } as Workspace,
21
+ };
22
+ };
23
+
24
+ const writeText = async (filePath: string, content: string) => {
25
+ await mkdir(path.dirname(filePath), { recursive: true });
26
+ await writeFile(filePath, content);
27
+ };
28
+
29
+ const moduleContext = (files: string[], name = "post"): AkanModuleContext => ({
30
+ kind: "domain",
31
+ name,
32
+ folderName: name,
33
+ sysName: "demo",
34
+ sysType: "app",
35
+ path: `apps/demo/lib/${name}`,
36
+ abstract: { path: `apps/demo/lib/${name}/${name}.abstract.md`, exists: true, headings: [] },
37
+ files,
38
+ });
39
+
40
+ describe("buildAkanModuleContextIndex", () => {
41
+ test("indexes constant, dictionary model, projection, spans, and partial presence without source bodies", async () => {
42
+ const { root, workspace } = await makeWorkspace();
43
+ const modulePath = path.join(root, "apps/demo/lib/post");
44
+ await writeText(
45
+ path.join(modulePath, "post.constant.ts"),
46
+ `
47
+ import { Int } from "akanjs/base";
48
+ import { via } from "akanjs/constant";
49
+
50
+ export class PostInput extends via((_field) => ({
51
+ title: _field(String),
52
+ count: _field(Int, { default: 0 }),
53
+ metadata: _field({ nested: ["secret"] }),
54
+ })) {}
55
+
56
+ export class PostObject extends via(PostInput, (field) => ({})) {}
57
+ export class LightPost extends via(PostObject, ["title"] as const, (resolve) => ({})) {}
58
+ export class Post extends via(PostObject, LightPost, (resolve) => ({})) {}
59
+ `,
60
+ );
61
+ await writeText(
62
+ path.join(modulePath, "post.dictionary.ts"),
63
+ `
64
+ import { modelDictionary } from "akanjs/dictionary";
65
+ import type { Post } from "./post.constant";
66
+
67
+ export const dictionary = modelDictionary(["en", "ko"])
68
+ .model<Post>((t) => ({
69
+ title: t(["Title", "제목"]),
70
+ }))
71
+ .slice<PostSlice>((fn) => ({
72
+ inPublic: fn(["Post In Public", "Post 공개"]).arg((t) => ({
73
+ ignored: t(["Ignored", "Ignored"]),
74
+ })),
75
+ }))
76
+ .translate({});
77
+ `,
78
+ );
79
+
80
+ const index = await buildAkanModuleContextIndex(
81
+ workspace,
82
+ moduleContext(["post.abstract.md", "post.constant.ts", "post.dictionary.ts"]),
83
+ { field: "count" },
84
+ );
85
+
86
+ expect(index).toMatchObject({
87
+ schemaVersion: 1,
88
+ app: "demo",
89
+ module: "post",
90
+ moduleClassName: "Post",
91
+ constant: { inputClassName: "PostInput", builderName: "_field" },
92
+ dictionary: { modelClassName: "Post", translatorName: "t" },
93
+ });
94
+ expect(index.constant?.fields.map((field) => [field.name, field.order, field.typeSummary])).toEqual([
95
+ ["title", 0, "_field(String)"],
96
+ ["count", 1, "_field(Int)"],
97
+ ["metadata", 2, "_field(object-literal)"],
98
+ ]);
99
+ expect(index.dictionary?.fields.map((field) => field.name)).toEqual(["title"]);
100
+ expect(index.dictionary?.fields.map((field) => field.name)).not.toContain("ignored");
101
+ expect(index.constant?.lightProjection?.fields.map((field) => field.name)).toEqual(["title"]);
102
+ expect(index.constant?.fields[0]?.sourceSpan).toMatchObject({
103
+ file: "apps/demo/lib/post/post.constant.ts",
104
+ startLine: expect.any(Number),
105
+ endLine: expect.any(Number),
106
+ startOffset: expect.any(Number),
107
+ endOffset: expect.any(Number),
108
+ });
109
+ expect(index.fieldPresence.find((field) => field.name === "count")).toMatchObject({
110
+ requested: true,
111
+ constant: true,
112
+ dictionary: false,
113
+ lightProjection: false,
114
+ });
115
+ expect(index.diagnostics.map((diagnostic) => diagnostic.code)).toContain("module-index-field-presence-partial");
116
+ expect(JSON.stringify(index)).not.toContain("export class");
117
+ expect(JSON.stringify(index)).not.toContain("modelDictionary");
118
+ expect(JSON.stringify(index)).not.toContain("secret");
119
+ expect(JSON.stringify(index)).not.toContain("제목");
120
+ });
121
+
122
+ test("reports missing and casing mismatch diagnostics with expected and actual paths", async () => {
123
+ const { root, workspace } = await makeWorkspace();
124
+ const modulePath = path.join(root, "apps/demo/lib/post");
125
+ await writeText(path.join(modulePath, "Post.Constant.ts"), "export class PostInput {}\n");
126
+
127
+ const index = await buildAkanModuleContextIndex(workspace, moduleContext(["Post.Constant.ts"]));
128
+
129
+ expect(index.files.find((file) => file.kind === "constant")).toMatchObject({
130
+ path: "apps/demo/lib/post/Post.Constant.ts",
131
+ expectedPath: "apps/demo/lib/post/post.constant.ts",
132
+ casing: "mismatch",
133
+ present: true,
134
+ });
135
+ expect(index.files.find((file) => file.kind === "dictionary")).toMatchObject({
136
+ expectedPath: "apps/demo/lib/post/post.dictionary.ts",
137
+ casing: "missing",
138
+ present: false,
139
+ });
140
+ expect(index.diagnostics.map((diagnostic) => diagnostic.code)).toContain("module-index-file-casing-mismatch");
141
+ expect(index.diagnostics.map((diagnostic) => diagnostic.code)).toContain("module-index-file-missing");
142
+ expect(index.diagnostics.flatMap((diagnostic) => diagnostic.context?.paths ?? [])).not.toContain(
143
+ "apps/demo/lib/post/Post.Template.tsx",
144
+ );
145
+ expect(index.diagnostics.flatMap((diagnostic) => diagnostic.context?.paths ?? [])).not.toContain(
146
+ "apps/demo/lib/post/post.service.ts",
147
+ );
148
+ });
149
+
150
+ test("indexes field builder callbacks, dictionary block returns, and pascal module names", async () => {
151
+ const { root, workspace } = await makeWorkspace();
152
+ const modulePath = path.join(root, "apps/demo/lib/blog-post");
153
+ await writeText(
154
+ path.join(modulePath, "blog-post.constant.ts"),
155
+ `
156
+ import { via } from "akanjs/constant";
157
+
158
+ export class BlogPostInput extends via((field) => ({
159
+ title: field(String),
160
+ })) {}
161
+
162
+ export class BlogPostObject extends via(BlogPostInput, (field) => ({})) {}
163
+ export class LightBlogPost extends via(BlogPostObject, ["title"] as const, (resolve) => ({})) {}
164
+ export class BlogPost extends via(BlogPostObject, LightBlogPost, (resolve) => ({})) {}
165
+ `,
166
+ );
167
+ await writeText(
168
+ path.join(modulePath, "blog-post.dictionary.ts"),
169
+ `
170
+ import { modelDictionary } from "akanjs/dictionary";
171
+ import type { BlogPost } from "./blog-post.constant";
172
+
173
+ export const dictionary = modelDictionary(["en"]).model<BlogPost>((t) => {
174
+ return {
175
+ title: t(["Title"]),
176
+ };
177
+ });
178
+ `,
179
+ );
180
+
181
+ const index = await buildAkanModuleContextIndex(
182
+ workspace,
183
+ moduleContext(["blog-post.abstract.md", "blog-post.constant.ts", "blog-post.dictionary.ts"], "blog-post"),
184
+ { field: "title" },
185
+ );
186
+
187
+ expect(index.moduleClassName).toBe("BlogPost");
188
+ expect(index.constant).toMatchObject({ inputClassName: "BlogPostInput", builderName: "field" });
189
+ expect(index.constant?.fields.map((field) => [field.name, field.typeSummary])).toEqual([
190
+ ["title", "field(String)"],
191
+ ]);
192
+ expect(index.dictionary).toMatchObject({ modelClassName: "BlogPost", translatorName: "t" });
193
+ expect(index.dictionary?.fields.map((field) => field.name)).toEqual(["title"]);
194
+ expect(index.constant?.lightProjection?.fields.map((field) => field.name)).toEqual(["title"]);
195
+ expect(index.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain(
196
+ "module-index-dictionary-model-missing",
197
+ );
198
+ expect(JSON.stringify(index)).not.toContain("return {");
199
+ });
200
+
201
+ test("reports parse and unsupported constant shape diagnostics", async () => {
202
+ const validDictionary = `
203
+ import { modelDictionary } from "akanjs/dictionary";
204
+ import type { Post } from "./post.constant";
205
+
206
+ export const dictionary = modelDictionary(["en"]).model<Post>((t) => ({}));
207
+ `;
208
+ const cases = [
209
+ {
210
+ name: "invalid-typescript",
211
+ constant: "export class PostInput extends via((field) => ({ title: field(String), })) {\n",
212
+ expectedCode: "module-index-typescript-parse-error",
213
+ },
214
+ {
215
+ name: "missing-input",
216
+ constant: "export class Post extends via(() => ({})) {}\n",
217
+ expectedCode: "module-index-constant-input-missing",
218
+ },
219
+ {
220
+ name: "missing-via",
221
+ constant: "export class PostInput {}\n",
222
+ expectedCode: "module-index-constant-via-missing",
223
+ },
224
+ {
225
+ name: "malformed-via",
226
+ constant: "export class PostInput extends via(PostObject) {}\n",
227
+ expectedCode: "module-index-constant-builder-missing",
228
+ },
229
+ {
230
+ name: "missing-builder-object",
231
+ constant: "export class PostInput extends via((field) => field(String)) {}\n",
232
+ expectedCode: "module-index-constant-builder-object-missing",
233
+ },
234
+ ];
235
+
236
+ for (const item of cases) {
237
+ const { root, workspace } = await makeWorkspace();
238
+ const modulePath = path.join(root, "apps/demo/lib/post");
239
+ await writeText(path.join(modulePath, "post.constant.ts"), item.constant);
240
+ await writeText(path.join(modulePath, "post.dictionary.ts"), validDictionary);
241
+
242
+ const index = await buildAkanModuleContextIndex(
243
+ workspace,
244
+ moduleContext(["post.abstract.md", "post.constant.ts", "post.dictionary.ts"]),
245
+ );
246
+
247
+ expect(
248
+ index.diagnostics.map((diagnostic) => diagnostic.code),
249
+ item.name,
250
+ ).toContain(item.expectedCode);
251
+ expect(JSON.stringify(index), item.name).not.toContain(item.constant.trim());
252
+ }
253
+ });
254
+
255
+ test("reports unsupported dictionary model callback shapes", async () => {
256
+ const validConstant = `
257
+ import { via } from "akanjs/constant";
258
+
259
+ export class PostInput extends via((field) => ({})) {}
260
+ `;
261
+ const cases = [
262
+ {
263
+ name: "missing-callback",
264
+ dictionary: 'export const dictionary = modelDictionary(["en"]).model<Post>();\n',
265
+ expectedCode: "module-index-dictionary-builder-missing",
266
+ },
267
+ {
268
+ name: "missing-object-return",
269
+ dictionary: 'export const dictionary = modelDictionary(["en"]).model<Post>((t) => t(["Title"]));\n',
270
+ expectedCode: "module-index-dictionary-builder-object-missing",
271
+ },
272
+ ];
273
+
274
+ for (const item of cases) {
275
+ const { root, workspace } = await makeWorkspace();
276
+ const modulePath = path.join(root, "apps/demo/lib/post");
277
+ await writeText(path.join(modulePath, "post.constant.ts"), validConstant);
278
+ await writeText(path.join(modulePath, "post.dictionary.ts"), item.dictionary);
279
+
280
+ const index = await buildAkanModuleContextIndex(
281
+ workspace,
282
+ moduleContext(["post.abstract.md", "post.constant.ts", "post.dictionary.ts"]),
283
+ );
284
+
285
+ expect(
286
+ index.diagnostics.map((diagnostic) => diagnostic.code),
287
+ item.name,
288
+ ).toContain(item.expectedCode);
289
+ expect(
290
+ index.diagnostics.map((diagnostic) => diagnostic.code),
291
+ item.name,
292
+ ).not.toContain("module-index-dictionary-model-missing");
293
+ expect(JSON.stringify(index), item.name).not.toContain(item.dictionary.trim());
294
+ }
295
+ });
296
+ });