@akanjs/devkit 2.1.1-rc.2 → 2.1.2-rc.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.
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ parseTypescriptFileBlocks,
4
+ preserveTypescriptResponseContent,
5
+ } from "./aiEditor";
6
+
7
+ describe("parseTypescriptFileBlocks", () => {
8
+ test("parses TypeScript file blocks with common fence variants", () => {
9
+ const writes = parseTypescriptFileBlocks(`
10
+ \`\`\`ts
11
+
12
+ // File: lib/car/car.constant.ts
13
+
14
+ export const car = "car";
15
+ \`\`\`
16
+
17
+ \`\`\`tsx
18
+ // File: lib/car/Car.Unit.tsx
19
+ export const CarUnit = () => null;
20
+ \`\`\`
21
+ `);
22
+
23
+ expect(writes).toEqual([
24
+ {
25
+ filePath: "lib/car/car.constant.ts",
26
+ content: 'export const car = "car";',
27
+ },
28
+ {
29
+ filePath: "lib/car/Car.Unit.tsx",
30
+ content: "export const CarUnit = () => null;",
31
+ },
32
+ ]);
33
+ });
34
+
35
+ test("keeps previous code response when validation responds with prose only", () => {
36
+ const previousContent = `
37
+ \`\`\`typescript
38
+ // File: lib/car/car.constant.ts
39
+ export const car = "car";
40
+ \`\`\`
41
+ `;
42
+ const nextContent =
43
+ "The generated file meets all specified requirements. No rewrite is necessary.";
44
+
45
+ expect(
46
+ preserveTypescriptResponseContent(previousContent, nextContent),
47
+ ).toBe(previousContent);
48
+ });
49
+
50
+ test("uses next code response when validation rewrites with parseable files", () => {
51
+ const previousContent = `
52
+ \`\`\`typescript
53
+ // File: lib/car/car.constant.ts
54
+ export const car = "car";
55
+ \`\`\`
56
+ `;
57
+ const nextContent = `
58
+ \`\`\`typescript
59
+ // File: lib/car/car.constant.ts
60
+ export const car = "updated";
61
+ \`\`\`
62
+ `;
63
+
64
+ expect(
65
+ preserveTypescriptResponseContent(previousContent, nextContent),
66
+ ).toBe(nextContent);
67
+ });
68
+ });
package/aiEditor.ts CHANGED
@@ -31,8 +31,41 @@ interface EditOptions {
31
31
  maxTry?: number;
32
32
  validate?: string[];
33
33
  approve?: boolean;
34
+ fallbackToPreviousTypescript?: boolean;
34
35
  }
35
36
 
37
+ export const parseTypescriptFileBlocks = (text: string): FileContent[] => {
38
+ const fileBlocks: FileContent[] = [];
39
+ const codeBlockRegex = /```(?:typescript|ts|tsx)\s*\n([\s\S]*?)```/gi;
40
+ const filePathRegex = /^\s*\/\/\s*File:\s*(.+?)\s*$/im;
41
+
42
+ for (const codeBlock of text.matchAll(codeBlockRegex)) {
43
+ const content = codeBlock[1]?.trim();
44
+ if (!content) continue;
45
+
46
+ const filePath = filePathRegex.exec(content)?.[1]?.trim();
47
+ if (!filePath) continue;
48
+
49
+ fileBlocks.push({
50
+ filePath,
51
+ content: content.replace(filePathRegex, "").trim(),
52
+ });
53
+ }
54
+
55
+ return fileBlocks;
56
+ };
57
+
58
+ export const preserveTypescriptResponseContent = (
59
+ previousContent: string,
60
+ nextContent: string,
61
+ ) => {
62
+ const previousWrites = parseTypescriptFileBlocks(previousContent);
63
+ const nextWrites = parseTypescriptFileBlocks(nextContent);
64
+ if (previousWrites.length > 0 && nextWrites.length === 0)
65
+ return previousContent;
66
+ return nextContent;
67
+ };
68
+
36
69
  export class AiSession {
37
70
  static #cacheDir = "node_modules/.cache/akan/aiSession";
38
71
  static #chat: ChatDeepSeek | ChatOpenAI | null = null;
@@ -189,8 +222,7 @@ export class AiSession {
189
222
  this.messageHistory.push(humanMessage);
190
223
  const stream = await AiSession.#chat.stream(this.messageHistory);
191
224
  let reasoningResponse = "",
192
- fullResponse = "",
193
- tokenIdx = 0;
225
+ fullResponse = "";
194
226
  for await (const chunk of stream) {
195
227
  if (loader.isSpinning())
196
228
  loader.succeed(`${AiSession.#chat.model} responded`);
@@ -214,13 +246,12 @@ export class AiSession {
214
246
  fullResponse += content;
215
247
  onChunk(content); // Send individual chunks to callback
216
248
  }
217
- tokenIdx++;
218
249
  }
219
250
  fullResponse += "\n";
220
251
  onChunk("\n");
221
252
  this.messageHistory.push(new AIMessage(fullResponse));
222
253
  return { content: fullResponse, messageHistory: this.messageHistory };
223
- } catch (error) {
254
+ } catch {
224
255
  loader.fail(`${AiSession.#chat.model} failed to respond`);
225
256
  throw new Error("Failed to stream response");
226
257
  }
@@ -233,6 +264,7 @@ export class AiSession {
233
264
  maxTry = MAX_ASK_TRY,
234
265
  validate,
235
266
  approve,
267
+ fallbackToPreviousTypescript,
236
268
  }: EditOptions = {},
237
269
  ) {
238
270
  for (let tryCount = 0; tryCount < maxTry; tryCount++) {
@@ -240,7 +272,19 @@ export class AiSession {
240
272
  if (validate?.length && tryCount === 0) {
241
273
  const validateQuestion = `Double check if the response meets the requirements and conditions, and follow the instructions. If not, rewrite it.
242
274
  ${validate.map((v) => `- ${v}`).join("\n")}`;
243
- response = await this.ask(validateQuestion, { onChunk, onReasoning });
275
+ const validateResponse = await this.ask(validateQuestion, {
276
+ onChunk,
277
+ onReasoning,
278
+ });
279
+ response = {
280
+ ...validateResponse,
281
+ content: fallbackToPreviousTypescript
282
+ ? preserveTypescriptResponseContent(
283
+ response.content,
284
+ validateResponse.content,
285
+ )
286
+ : validateResponse.content,
287
+ };
244
288
  }
245
289
  const isConfirmed = approve
246
290
  ? true
@@ -287,15 +331,35 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
287
331
  executor: Executor,
288
332
  options: EditOptions = {},
289
333
  ) {
290
- const content = await this.edit(question, options);
334
+ const content = await this.edit(question, {
335
+ ...options,
336
+ fallbackToPreviousTypescript: true,
337
+ });
291
338
  const writes = this.#getTypescriptCodes(content);
339
+ if (!writes.length)
340
+ throw new Error(
341
+ "No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
342
+ );
292
343
  for (const write of writes)
293
344
  await executor.writeFile(write.filePath, write.content);
294
345
  return await this.#tryFixTypescripts(writes, executor, options);
295
346
  }
296
- async #editTypescripts(question: string, options: EditOptions = {}) {
297
- const content = await this.edit(question, options);
298
- return this.#getTypescriptCodes(content);
347
+ async #editTypescripts(
348
+ question: string,
349
+ options: EditOptions = {},
350
+ fallbackWrites?: FileContent[],
351
+ ) {
352
+ const content = await this.edit(question, {
353
+ ...options,
354
+ fallbackToPreviousTypescript: true,
355
+ });
356
+ const writes = this.#getTypescriptCodes(content);
357
+ if (!writes.length && fallbackWrites?.length) return fallbackWrites;
358
+ if (!writes.length)
359
+ throw new Error(
360
+ "No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
361
+ );
362
+ return writes;
299
363
  }
300
364
  async #tryFixTypescripts(
301
365
  writes: FileContent[],
@@ -309,15 +373,16 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
309
373
  }).start();
310
374
  const fileChecks = await Promise.all(
311
375
  writes.map(async ({ filePath }) => {
312
- const typeCheckResult = executor.typeCheck(filePath);
313
- const lintResult = await executor.lint(filePath);
314
- const needFix =
315
- !!typeCheckResult.fileErrors.length || !!lintResult.errors.length;
376
+ const lintResult = await executor.lint(filePath, { fix: true });
377
+ const typeCheckResult = await executor.typeCheckAsync(filePath);
378
+ const hasTypeErrors = typeCheckResult.fileErrors.length > 0;
379
+ const hasLintErrors = lintResult.errors.length > 0;
380
+ const needFix = hasTypeErrors || hasLintErrors;
316
381
  return { filePath, typeCheckResult, lintResult, needFix };
317
382
  }),
318
383
  );
319
- const needFix = fileChecks.some((fileCheck) => fileCheck.needFix);
320
- if (needFix) {
384
+ const hasAnyFix = fileChecks.some((fileCheck) => fileCheck.needFix);
385
+ if (hasAnyFix) {
321
386
  loader.fail(
322
387
  "Type checking and linting has some errors, try to fix them",
323
388
  );
@@ -337,6 +402,7 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
337
402
  validate: undefined,
338
403
  approve: true,
339
404
  },
405
+ writes,
340
406
  );
341
407
  for (const write of writes)
342
408
  await executor.writeFile(write.filePath, write.content);
@@ -348,19 +414,7 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
348
414
  throw new Error("Failed to create scalar");
349
415
  }
350
416
  #getTypescriptCodes(text: string): FileContent[] {
351
- const codes = text.match(/```(typescript|tsx)([\s\S]*?)```/g);
352
- if (!codes) return [];
353
- const result = codes.map((code) => {
354
- const content = /```(typescript|tsx)([\s\S]*?)```/.exec(code)?.[2];
355
- if (!content) return null;
356
- const filePath = /\/\/ File: (.*?)(?:\n|$)/.exec(content)?.[1]?.trim();
357
- if (!filePath) return null;
358
- const contentWithoutFilepath = content
359
- .replace(`// File: ${filePath}\n`, "")
360
- .trim();
361
- return { filePath, content: contentWithoutFilepath };
362
- });
363
- return result.filter((code) => code !== null) as FileContent[];
417
+ return parseTypescriptFileBlocks(text);
364
418
  }
365
419
  async editMarkdown(request: string, options: EditOptions = {}) {
366
420
  const content = await this.edit(request, options);
@@ -7,7 +7,13 @@ import { AkanAppConfig, AkanLibConfig } from "./akanConfig";
7
7
  import type { DeepPartial, LibConfigResult } from "./types";
8
8
 
9
9
  const akanPackageJson = JSON.parse(
10
- fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
10
+ fs.readFileSync(
11
+ path.join(
12
+ path.dirname(fileURLToPath(import.meta.url)),
13
+ "../../../akanjs/package.json",
14
+ ),
15
+ "utf8",
16
+ ),
11
17
  ) as PackageJson;
12
18
 
13
19
  const packageJson: PackageJson = {
@@ -34,7 +40,13 @@ const baseDevEnv = {
34
40
 
35
41
  describe("AkanAppConfig", () => {
36
42
  test("applies defaults for route domains, i18n, image, mobile, and imports", () => {
37
- const config = new AkanAppConfig(app, ["shared"], packageJson, {}, baseDevEnv);
43
+ const config = new AkanAppConfig(
44
+ app,
45
+ ["shared"],
46
+ packageJson,
47
+ {},
48
+ baseDevEnv,
49
+ );
38
50
 
39
51
  expect([...config.domains].sort()).toEqual([
40
52
  "portal-debug.akanjs.com",
@@ -61,7 +73,11 @@ describe("AkanAppConfig", () => {
61
73
  },
62
74
  });
63
75
  expect(config.barrelImports).toEqual(
64
- expect.arrayContaining(["@apps/portal/ui", "@libs/shared/server", "akanjs/common"]),
76
+ expect.arrayContaining([
77
+ "@apps/portal/ui",
78
+ "@libs/shared/server",
79
+ "akanjs/common",
80
+ ]),
65
81
  );
66
82
  expect(config.docker.content).toContain("ENV AKAN_PUBLIC_APP_NAME=portal");
67
83
  expect(process.env.AKAN_PUBLIC_DEFAULT_LOCALE).toBe("en");
@@ -75,10 +91,21 @@ describe("AkanAppConfig", () => {
75
91
  {
76
92
  routes: [
77
93
  { domains: { debug: ["Root.Local:8282"], qa: ["QA.Root.Local"] } },
78
- { basePath: "/admin/", domains: { debug: ["Admin.Local:8282"], main: ["Admin.Main.Local"] } },
94
+ {
95
+ basePath: "/admin/",
96
+ domains: {
97
+ debug: ["Admin.Local:8282"],
98
+ main: ["Admin.Main.Local"],
99
+ },
100
+ },
79
101
  ],
80
102
  i18n: { locales: ["ko", "en"], defaultLocale: "ko" },
81
- mobile: { appName: "Portal App", appId: "com.portal.mobile", version: "1.2.3", buildNum: 7 },
103
+ mobile: {
104
+ appName: "Portal App",
105
+ appId: "com.portal.mobile",
106
+ version: "1.2.3",
107
+ buildNum: 7,
108
+ },
82
109
  images: { qualities: [80, 90], dangerouslyAllowSVG: true },
83
110
  docker: {
84
111
  image: { amd64: "oven/bun:amd64", arm64: "oven/bun:arm64" },
@@ -102,7 +129,12 @@ describe("AkanAppConfig", () => {
102
129
  "admin.local",
103
130
  "admin.main.local",
104
131
  ]);
105
- expect([...config.branches].sort()).toEqual(["debug", "develop", "main", "qa"]);
132
+ expect([...config.branches].sort()).toEqual([
133
+ "debug",
134
+ "develop",
135
+ "main",
136
+ "qa",
137
+ ]);
106
138
  expect(config.i18n.defaultLocale).toBe("ko");
107
139
  expect(config.images.qualities).toEqual([80, 90]);
108
140
  expect(config.images.dangerouslyAllowSVG).toBe(true);
@@ -122,9 +154,17 @@ describe("AkanAppConfig", () => {
122
154
  });
123
155
 
124
156
  test("creates production package json and reports missing external versions", () => {
125
- const config = new AkanAppConfig(app, [], packageJson, { externalLibs: ["@external/runtime"] }, baseDevEnv);
157
+ const config = new AkanAppConfig(
158
+ app,
159
+ [],
160
+ packageJson,
161
+ { externalLibs: ["@external/runtime"] },
162
+ baseDevEnv,
163
+ );
126
164
 
127
- expect(config.getProductionPackageJson({ scripts: { start: "bun main.js" } })).toMatchObject({
165
+ expect(
166
+ config.getProductionPackageJson({ scripts: { start: "bun main.js" } }),
167
+ ).toMatchObject({
128
168
  name: "portal",
129
169
  main: "./main.js",
130
170
  scripts: { start: "bun main.js" },
@@ -145,11 +185,16 @@ describe("AkanAppConfig", () => {
145
185
  { externalLibs: ["missing-lib"] },
146
186
  baseDevEnv,
147
187
  );
148
- expect(() => brokenConfig.getProductionPackageJson()).toThrow("Dependency missing-lib not found");
188
+ expect(() => brokenConfig.getProductionPackageJson()).toThrow(
189
+ "Dependency missing-lib not found",
190
+ );
149
191
  });
150
192
 
151
193
  test("falls back to akanjs package versions for built-in runtime dependencies", () => {
152
- const runtimeDependencies = { ...akanPackageJson.dependencies, ...akanPackageJson.peerDependencies };
194
+ const runtimeDependencies = {
195
+ ...akanPackageJson.dependencies,
196
+ ...akanPackageJson.peerDependencies,
197
+ };
153
198
  const config = new AkanAppConfig(
154
199
  app,
155
200
  [],
@@ -168,44 +213,130 @@ describe("AkanAppConfig", () => {
168
213
  expect(config.getProductionPackageJson().dependencies).toEqual({
169
214
  react: runtimeDependencies.react,
170
215
  "react-dom": runtimeDependencies["react-dom"],
171
- "react-server-dom-webpack": runtimeDependencies["react-server-dom-webpack"],
216
+ "react-server-dom-webpack":
217
+ runtimeDependencies["react-server-dom-webpack"],
172
218
  croner: runtimeDependencies.croner,
173
219
  sharp: runtimeDependencies.sharp,
174
220
  });
175
221
  });
176
222
 
177
223
  test("adds backend runtime packages by database mode", () => {
178
- const runtimeDependencies = { ...akanPackageJson.dependencies, ...akanPackageJson.peerDependencies };
179
- const singleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "single" }, baseDevEnv);
180
- const multipleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "multiple" }, baseDevEnv);
181
- const clusterConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "cluster" }, baseDevEnv);
224
+ const runtimeDependencies = {
225
+ ...akanPackageJson.dependencies,
226
+ ...akanPackageJson.peerDependencies,
227
+ };
228
+ const singleConfig = new AkanAppConfig(
229
+ app,
230
+ [],
231
+ packageJson,
232
+ { defaultDatabaseMode: "single" },
233
+ baseDevEnv,
234
+ );
235
+ const multipleConfig = new AkanAppConfig(
236
+ app,
237
+ [],
238
+ packageJson,
239
+ { defaultDatabaseMode: "multiple" },
240
+ baseDevEnv,
241
+ );
242
+ const clusterConfig = new AkanAppConfig(
243
+ app,
244
+ [],
245
+ packageJson,
246
+ { defaultDatabaseMode: "cluster" },
247
+ baseDevEnv,
248
+ );
182
249
 
183
250
  expect(singleConfig.getProductionPackageJson().dependencies).toMatchObject({
184
251
  croner: runtimeDependencies.croner,
185
252
  });
186
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("ioredis");
187
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("bullmq");
188
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
189
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
190
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("protobufjs");
253
+ expect(
254
+ singleConfig.getProductionPackageJson().dependencies,
255
+ ).not.toHaveProperty("ioredis");
256
+ expect(
257
+ singleConfig.getProductionPackageJson().dependencies,
258
+ ).not.toHaveProperty("bullmq");
259
+ expect(
260
+ singleConfig.getProductionPackageJson().dependencies,
261
+ ).not.toHaveProperty("@libsql/client");
262
+ expect(
263
+ singleConfig.getProductionPackageJson().dependencies,
264
+ ).not.toHaveProperty("postgres");
265
+ expect(
266
+ singleConfig.getProductionPackageJson().dependencies,
267
+ ).not.toHaveProperty("protobufjs");
191
268
 
192
- expect(multipleConfig.getProductionPackageJson().dependencies).toMatchObject({
269
+ expect(
270
+ multipleConfig.getProductionPackageJson().dependencies,
271
+ ).toMatchObject({
193
272
  "@libsql/client": runtimeDependencies["@libsql/client"],
194
273
  bullmq: runtimeDependencies.bullmq,
195
274
  croner: runtimeDependencies.croner,
196
275
  ioredis: runtimeDependencies.ioredis,
197
276
  protobufjs: runtimeDependencies.protobufjs,
198
277
  });
199
- expect(multipleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
278
+ expect(
279
+ multipleConfig.getProductionPackageJson().dependencies,
280
+ ).not.toHaveProperty("postgres");
200
281
 
201
- expect(clusterConfig.getProductionPackageJson().dependencies).toMatchObject({
202
- bullmq: runtimeDependencies.bullmq,
203
- croner: runtimeDependencies.croner,
204
- ioredis: runtimeDependencies.ioredis,
205
- postgres: runtimeDependencies.postgres,
206
- protobufjs: runtimeDependencies.protobufjs,
207
- });
208
- expect(clusterConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
282
+ expect(clusterConfig.getProductionPackageJson().dependencies).toMatchObject(
283
+ {
284
+ bullmq: runtimeDependencies.bullmq,
285
+ croner: runtimeDependencies.croner,
286
+ ioredis: runtimeDependencies.ioredis,
287
+ postgres: runtimeDependencies.postgres,
288
+ protobufjs: runtimeDependencies.protobufjs,
289
+ },
290
+ );
291
+ expect(
292
+ clusterConfig.getProductionPackageJson().dependencies,
293
+ ).not.toHaveProperty("@libsql/client");
294
+ });
295
+
296
+ test("resolves database mode runtime packages and missing install specs", () => {
297
+ const runtimeDependencies = {
298
+ ...akanPackageJson.dependencies,
299
+ ...akanPackageJson.peerDependencies,
300
+ };
301
+ const config = new AkanAppConfig(
302
+ app,
303
+ [],
304
+ {
305
+ name: "repo",
306
+ version: "1.0.0",
307
+ description: "repo",
308
+ dependencies: {
309
+ bullmq: "5.0.0",
310
+ },
311
+ devDependencies: {
312
+ ioredis: "5.0.0",
313
+ },
314
+ },
315
+ {},
316
+ baseDevEnv,
317
+ );
318
+
319
+ expect(config.getDatabaseModeRuntimePackages("single")).toEqual([]);
320
+ expect(config.getDatabaseModeRuntimePackages("multiple")).toEqual([
321
+ "@libsql/client",
322
+ "bullmq",
323
+ "ioredis",
324
+ "protobufjs",
325
+ ]);
326
+ expect(config.getDatabaseModeRuntimePackages("cluster")).toEqual([
327
+ "bullmq",
328
+ "ioredis",
329
+ "postgres",
330
+ "protobufjs",
331
+ ]);
332
+ expect(config.getMissingDatabaseModeDependencySpecs("multiple")).toEqual([
333
+ `@libsql/client@${runtimeDependencies["@libsql/client"]}`,
334
+ `protobufjs@${runtimeDependencies.protobufjs}`,
335
+ ]);
336
+ expect(config.getMissingDatabaseModeDependencySpecs("cluster")).toEqual([
337
+ `postgres@${runtimeDependencies.postgres}`,
338
+ `protobufjs@${runtimeDependencies.protobufjs}`,
339
+ ]);
209
340
  });
210
341
 
211
342
  test("normalizes multiple mobile targets and validates base paths", () => {
@@ -266,7 +397,11 @@ describe("AkanLibConfig", () => {
266
397
  const lib = { name: "shared" } as never;
267
398
  expect(new AkanLibConfig(lib, {}).externalLibs).toEqual([]);
268
399
 
269
- const config: DeepPartial<LibConfigResult> = { externalLibs: ["firebase-admin"] };
270
- expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
400
+ const config: DeepPartial<LibConfigResult> = {
401
+ externalLibs: ["firebase-admin"],
402
+ };
403
+ expect(new AkanLibConfig(lib, config).externalLibs).toEqual([
404
+ "firebase-admin",
405
+ ]);
271
406
  });
272
407
  });