@akanjs/devkit 2.1.1-rc.2 → 2.1.1

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);