@akanjs/devkit 2.3.9-rc.0 → 2.3.9-rc.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.
package/aiEditor.test.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import {
3
- parseTypescriptFileBlocks,
4
- preserveTypescriptResponseContent,
5
- } from "./aiEditor";
2
+ import { parseTypescriptFileBlocks, preserveTypescriptResponseContent } from "./aiEditor";
6
3
 
7
4
  describe("parseTypescriptFileBlocks", () => {
8
5
  test("parses TypeScript file blocks with common fence variants", () => {
@@ -39,12 +36,9 @@ export const CarUnit = () => null;
39
36
  export const car = "car";
40
37
  \`\`\`
41
38
  `;
42
- const nextContent =
43
- "The generated file meets all specified requirements. No rewrite is necessary.";
39
+ const nextContent = "The generated file meets all specified requirements. No rewrite is necessary.";
44
40
 
45
- expect(
46
- preserveTypescriptResponseContent(previousContent, nextContent),
47
- ).toBe(previousContent);
41
+ expect(preserveTypescriptResponseContent(previousContent, nextContent)).toBe(previousContent);
48
42
  });
49
43
 
50
44
  test("uses next code response when validation rewrites with parseable files", () => {
@@ -61,8 +55,6 @@ export const car = "updated";
61
55
  \`\`\`
62
56
  `;
63
57
 
64
- expect(
65
- preserveTypescriptResponseContent(previousContent, nextContent),
66
- ).toBe(nextContent);
58
+ expect(preserveTypescriptResponseContent(previousContent, nextContent)).toBe(nextContent);
67
59
  });
68
60
  });
package/akanContext.ts CHANGED
@@ -4,6 +4,7 @@ import { capitalize } from "akanjs/common";
4
4
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
5
5
  import { FileSys } from "./fileSys";
6
6
  import type { PackageJson } from "./types";
7
+ import type { RepairAction } from "./workflow";
7
8
 
8
9
  export type AkanContextFormat = "json" | "markdown";
9
10
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -59,14 +60,94 @@ export interface AkanDiagnostic {
59
60
  code: string;
60
61
  message: string;
61
62
  path?: string;
63
+ repairActions?: RepairAction[];
62
64
  }
63
65
 
64
66
  export interface AkanDoctorResult {
65
67
  schemaVersion: 1;
68
+ repoName: string;
69
+ root: string;
66
70
  strict: boolean;
71
+ status: "passed" | "failed";
67
72
  diagnostics: AkanDiagnostic[];
73
+ generatedFiles: string[];
74
+ generatedFilesFreshness: {
75
+ status: "unknown";
76
+ message: string;
77
+ refreshCommand: string;
78
+ verifyingCommands: string[];
79
+ };
80
+ validationCommands: string[];
81
+ repairActions: RepairAction[];
68
82
  }
69
83
 
84
+ export type JsonRpcRequest = {
85
+ jsonrpc?: "2.0";
86
+ id?: string | number | null;
87
+ method: string;
88
+ params?: Record<string, unknown>;
89
+ };
90
+
91
+ export type McpFraming = "content-length" | "newline";
92
+ export type AkanMcpMode = "readonly" | "plan" | "apply";
93
+
94
+ export type CursorMcpConfig = {
95
+ mcpServers?: Record<string, unknown>;
96
+ };
97
+
98
+ export const resourceList = [
99
+ { uri: "akan://docs/framework", name: "Akan framework guide", mimeType: "text/markdown" },
100
+ { uri: "akan://guidelines/framework", name: "Framework guideline", mimeType: "text/markdown" },
101
+ { uri: "akan://guidelines/modelSignal", name: "Model signal guideline", mimeType: "text/markdown" },
102
+ { uri: "akan://workspace/summary", name: "Workspace summary", mimeType: "application/json" },
103
+ { uri: "akan://workspace/apps", name: "Workspace apps", mimeType: "application/json" },
104
+ { uri: "akan://workspace/modules", name: "Workspace modules", mimeType: "application/json" },
105
+ ];
106
+
107
+ export const cursorMcpConfigPath = ".cursor/mcp.json";
108
+
109
+ const cursorWorkspaceFolder = "$" + "{workspaceFolder}";
110
+
111
+ export const createAkanCursorMcpServer = (mode: AkanMcpMode = "readonly") => ({
112
+ type: "stdio",
113
+ command: "bash",
114
+ args: ["-lc", `cd "${cursorWorkspaceFolder}" && akan mcp --mode ${mode}`],
115
+ });
116
+
117
+ export const akanCursorMcpServer = createAkanCursorMcpServer();
118
+
119
+ export const renderDoctorText = (result: AkanDoctorResult) => {
120
+ const lines = [`Akan doctor status: ${result.status}`];
121
+ if (result.diagnostics.length === 0) {
122
+ lines.push("", "No Akan workspace diagnostics found.");
123
+ } else {
124
+ lines.push(
125
+ "",
126
+ ...result.diagnostics.map((diagnostic) =>
127
+ [
128
+ `[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`,
129
+ diagnostic.path ? ` ${diagnostic.path}` : "",
130
+ ]
131
+ .filter(Boolean)
132
+ .join("\n"),
133
+ ),
134
+ );
135
+ }
136
+ lines.push(
137
+ "",
138
+ "Generated file freshness:",
139
+ result.generatedFilesFreshness.message,
140
+ `Refresh: ${result.generatedFilesFreshness.refreshCommand}`,
141
+ "",
142
+ "Repair actions:",
143
+ ...(result.repairActions.length ? result.repairActions.map((action) => `- ${action.command}`) : ["- none"]),
144
+ "",
145
+ "Validation commands:",
146
+ ...result.validationCommands.map((command) => `- ${command}`),
147
+ );
148
+ return `${lines.join("\n")}\n`;
149
+ };
150
+
70
151
  export interface AkanContextOptions {
71
152
  app?: string | null;
72
153
  module?: string | null;
@@ -74,17 +155,69 @@ export interface AkanContextOptions {
74
155
  }
75
156
 
76
157
  const generatedFiles = [
77
- "cnst.ts",
78
- "db.ts",
79
- "dict.ts",
80
- "option.ts",
81
- "sig.ts",
82
- "srv.ts",
83
- "st.ts",
84
- "useClient.ts",
85
- "useServer.ts",
158
+ "apps/*/client.ts",
159
+ "apps/*/server.ts",
160
+ "*/lib/cnst.ts",
161
+ "*/lib/db.ts",
162
+ "*/lib/dict.ts",
163
+ "*/lib/option.ts",
164
+ "*/lib/sig.ts",
165
+ "*/lib/srv.ts",
166
+ "*/lib/st.ts",
167
+ "*/lib/useClient.ts",
168
+ "*/lib/useServer.ts",
169
+ "*/lib/**/index.ts",
170
+ "*/ui/index.ts",
171
+ "*/webkit/index.ts",
172
+ "*/srvkit/index.ts",
173
+ "*/common/index.ts",
174
+ ];
175
+
176
+ const validationCommands = [
177
+ "akan sync <app-or-lib>",
178
+ "akan lint <app-or-lib-or-pkg>",
179
+ "akan typecheck <app-name>",
180
+ "akan test <app-or-lib-or-pkg>",
181
+ "akan build <app-name>",
182
+ "akan doctor --strict --format json",
86
183
  ];
87
184
 
185
+ const generatedFilesFreshness = {
186
+ status: "unknown" as const,
187
+ message: "Run sync before validation so generated Akan files match the current source conventions.",
188
+ refreshCommand: "akan sync <app-or-lib>",
189
+ verifyingCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>"],
190
+ };
191
+
192
+ const repairAction = (
193
+ kind: RepairAction["kind"],
194
+ command: string,
195
+ reason: string,
196
+ safeToRun: boolean,
197
+ ): RepairAction => ({
198
+ kind,
199
+ command,
200
+ reason,
201
+ safeToRun,
202
+ });
203
+
204
+ const moduleShapeFiles = (module: AkanModuleContext) => {
205
+ if (module.kind === "service") {
206
+ return [`${module.name}.dictionary.ts`, `${module.name}.service.ts`, `${module.name}.signal.ts`];
207
+ }
208
+ if (module.kind === "scalar") return [`${module.name}.constant.ts`, `${module.name}.dictionary.ts`];
209
+ return [
210
+ `${module.name}.constant.ts`,
211
+ `${module.name}.dictionary.ts`,
212
+ `${module.name}.service.ts`,
213
+ `${module.name}.store.ts`,
214
+ `${module.name}.signal.ts`,
215
+ ];
216
+ };
217
+
218
+ const constantFieldNames = (content: string) =>
219
+ [...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
220
+
88
221
  const appRootAllowFiles = new Set([
89
222
  "akan.app.json",
90
223
  "akan.config.ts",
@@ -283,7 +416,7 @@ export class AkanContextAnalyzer {
283
416
  libs,
284
417
  pkgs,
285
418
  generatedFiles,
286
- validationCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>", "akan start <app-name>"],
419
+ validationCommands,
287
420
  };
288
421
  }
289
422
 
@@ -293,18 +426,35 @@ export class AkanContextAnalyzer {
293
426
  ): Promise<AkanDoctorResult> {
294
427
  const context = await AkanContextAnalyzer.analyze(workspace);
295
428
  const diagnostics: AkanDiagnostic[] = [];
429
+ const repairActions: RepairAction[] = [
430
+ repairAction("generated", "akan repair generated --app <app-or-lib>", "Refresh generated Akan files.", true),
431
+ repairAction(
432
+ "format",
433
+ "akan repair format --target <app-or-lib-or-pkg>",
434
+ "Run the formatter/linter repair path.",
435
+ true,
436
+ ),
437
+ ];
296
438
 
297
439
  for (const app of context.apps) {
298
440
  const appPath = path.join(workspace.workspaceRoot, app.path);
299
441
  for (const entry of await safeReadDir(appPath)) {
300
442
  const allowed = entry.isDirectory() ? appRootAllowDirs.has(entry.name) : appRootAllowFiles.has(entry.name);
301
443
  if (!allowed) {
444
+ const action = repairAction(
445
+ "module-shape",
446
+ `akan repair module-shape --app ${app.name}`,
447
+ "Review app root shape and remove or move the unknown entry.",
448
+ false,
449
+ );
302
450
  diagnostics.push({
303
- severity: "warning",
451
+ severity: "error",
304
452
  code: "app-root-unknown-entry",
305
453
  path: `${app.path}/${entry.name}`,
306
454
  message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in app root: ${app.path}/${entry.name}`,
455
+ repairActions: [action],
307
456
  });
457
+ repairActions.push(action);
308
458
  }
309
459
  }
310
460
  }
@@ -312,17 +462,80 @@ export class AkanContextAnalyzer {
312
462
  for (const sys of [...context.apps, ...context.libs]) {
313
463
  for (const module of sys.modules) {
314
464
  if (!module.abstract.exists) {
465
+ const action = repairAction(
466
+ "module-shape",
467
+ `akan repair module-shape --app ${sys.name} --module ${module.name}`,
468
+ "Create the missing module abstract or inspect required source files.",
469
+ false,
470
+ );
315
471
  diagnostics.push({
316
472
  severity: strict ? "error" : "warning",
317
473
  code: "module-abstract-missing",
318
474
  path: module.abstract.path,
319
475
  message: `${capitalize(module.kind)} module ${sys.name}:${module.name} should include ${module.abstract.path}`,
476
+ repairActions: [action],
477
+ });
478
+ repairActions.push(action);
479
+ }
480
+ const missingFiles = moduleShapeFiles(module).filter((filename) => !module.files.includes(filename));
481
+ if (missingFiles.length) {
482
+ const action = repairAction(
483
+ "module-shape",
484
+ `akan repair module-shape --app ${sys.name} --module ${module.name}`,
485
+ "Review missing required module source files.",
486
+ false,
487
+ );
488
+ diagnostics.push({
489
+ severity: "error",
490
+ code: "module-shape-invalid",
491
+ path: module.path,
492
+ message: `${capitalize(module.kind)} module ${sys.name}:${module.name} is missing required files: ${missingFiles.join(", ")}`,
493
+ repairActions: [action],
320
494
  });
495
+ repairActions.push(action);
496
+ }
497
+ if (module.kind !== "service" && module.files.includes(`${module.name}.dictionary.ts`)) {
498
+ const constantPath = path.join(workspace.workspaceRoot, module.path, `${module.name}.constant.ts`);
499
+ const dictionaryPath = path.join(workspace.workspaceRoot, module.path, `${module.name}.dictionary.ts`);
500
+ const [constantContent, dictionaryContent] = await Promise.all([
501
+ safeReadText(constantPath),
502
+ safeReadText(dictionaryPath),
503
+ ]);
504
+ if (constantContent && dictionaryContent) {
505
+ for (const fieldName of constantFieldNames(constantContent)) {
506
+ if (new RegExp(`\\b${fieldName}\\s*:`).test(dictionaryContent)) continue;
507
+ const action = repairAction(
508
+ "dictionary",
509
+ `akan repair dictionary --app ${sys.name} --module ${module.name}`,
510
+ "Add missing dictionary labels for source constant fields.",
511
+ false,
512
+ );
513
+ diagnostics.push({
514
+ severity: "warning",
515
+ code: "dictionary-label-missing",
516
+ path: `${module.path}/${module.name}.dictionary.ts`,
517
+ message: `Dictionary labels for ${sys.name}:${module.name}.${fieldName} were not found.`,
518
+ repairActions: [action],
519
+ });
520
+ repairActions.push(action);
521
+ }
522
+ }
321
523
  }
322
524
  }
323
525
  }
324
526
 
325
- return { schemaVersion: 1, strict, diagnostics };
527
+ return {
528
+ schemaVersion: 1,
529
+ repoName: context.repoName,
530
+ root: context.root,
531
+ strict,
532
+ status: diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "failed" : "passed",
533
+ diagnostics,
534
+ generatedFiles: context.generatedFiles,
535
+ generatedFilesFreshness,
536
+ validationCommands: context.validationCommands,
537
+ repairActions,
538
+ };
326
539
  }
327
540
 
328
541
  static findModules(context: AkanWorkspaceContext, moduleName?: string | null) {
@@ -130,16 +130,14 @@ describe("materializeCapacitorConfig", () => {
130
130
  describe("root capacitor config helpers", () => {
131
131
  test("clears root configs before writing the temporary json config", async () => {
132
132
  const root = await makeTempRoot();
133
- await Promise.all(
134
- rootCapacitorConfigFilenames.map((file) => writeFile(path.join(root, file), `old ${file}\n`)),
135
- );
133
+ await Promise.all(rootCapacitorConfigFilenames.map((file) => writeFile(path.join(root, file), `old ${file}\n`)));
136
134
 
137
- await writeRootCapacitorConfig(root, "{\n \"appId\": \"com.minimal.app\"\n}\n");
135
+ await writeRootCapacitorConfig(root, '{\n "appId": "com.minimal.app"\n}\n');
138
136
 
139
137
  expect(await Bun.file(path.join(root, "capacitor.config.ts")).exists()).toBe(false);
140
138
  expect(await Bun.file(path.join(root, "capacitor.config.js")).exists()).toBe(false);
141
139
  expect(await Bun.file(path.join(root, "capacitor.config.json")).text()).toBe(
142
- "{\n \"appId\": \"com.minimal.app\"\n}\n",
140
+ '{\n "appId": "com.minimal.app"\n}\n',
143
141
  );
144
142
 
145
143
  await clearRootCapacitorConfigs(root);
@@ -186,7 +184,9 @@ describe("iOS native run helpers", () => {
186
184
  },
187
185
  }),
188
186
  ),
189
- ).toEqual([{ id: "11111111-2222-3333-4444-555555555555", name: "iPhone 16", kind: "simulator", state: "Shutdown" }]);
187
+ ).toEqual([
188
+ { id: "11111111-2222-3333-4444-555555555555", name: "iPhone 16", kind: "simulator", state: "Shutdown" },
189
+ ]);
190
190
  });
191
191
 
192
192
  test("builds xcodebuild destinations and output paths by target kind", () => {
@@ -198,7 +198,9 @@ describe("iOS native run helpers", () => {
198
198
  });
199
199
  expect(deviceCommand.xcodebuildArgs).toContain("id=device-1");
200
200
  expect(deviceCommand.xcodebuildArgs).toContain("App QA");
201
- expect(deviceCommand.appPath).toBe("/repo/apps/minimal/ios/DerivedData/device-1/Build/Products/Debug-iphoneos/App.app");
201
+ expect(deviceCommand.appPath).toBe(
202
+ "/repo/apps/minimal/ios/DerivedData/device-1/Build/Products/Debug-iphoneos/App.app",
203
+ );
202
204
 
203
205
  const simulatorCommand = buildIosNativeRunCommand({
204
206
  appRoot: "/repo/apps/minimal",
@@ -212,7 +214,11 @@ describe("iOS native run helpers", () => {
212
214
 
213
215
  test("classifies iOS signing and device-state failures", () => {
214
216
  expect(classifyIosRunFailure("There are no accounts registered with Xcode").kind).toBe("apple-account");
215
- expect(classifyIosRunFailure('Failed Registering Bundle Identifier: The app identifier "com.minimal.app" cannot be registered to your development team').kind).toBe("bundle-identifier");
217
+ expect(
218
+ classifyIosRunFailure(
219
+ 'Failed Registering Bundle Identifier: The app identifier "com.minimal.app" cannot be registered to your development team',
220
+ ).kind,
221
+ ).toBe("bundle-identifier");
216
222
  expect(classifyIosRunFailure("No profiles for 'com.minimal.app' were found").kind).toBe("provisioning-profile");
217
223
  expect(classifyIosRunFailure("Developer Mode is disabled on this device").kind).toBe("device-state");
218
224
  expect(classifyIosRunFailure("arm64-apple-darwin20.0: error: unknown argument: '-index-store-path'").kind).toBe(
@@ -165,7 +165,7 @@ const assertCurrentDirectoryIsWorkspaceRoot = async () => {
165
165
  );
166
166
  };
167
167
 
168
- const getInternalArgumentValue = async (
168
+ export const getInternalArgumentValue = async (
169
169
  argMeta: InternalArgMeta,
170
170
  value: string | undefined,
171
171
  workspace: WorkspaceExecutor,
@@ -201,6 +201,7 @@ const getInternalArgumentValue = async (
201
201
  }
202
202
  } else if (sysType === "app") {
203
203
  if (value && appNames.includes(value)) return AppExecutor.from(workspace, value);
204
+ if (!value && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
204
205
  const appName = await select<string>({ message: `Select the ${sysType} name`, choices: appNames });
205
206
  return AppExecutor.from(workspace, appName);
206
207
  } else if (sysType === "lib") {
@@ -1,5 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { AppExecutor, WorkspaceExecutor } from "../executors";
2
3
  import { App, getArgMetas, Lib, Module, Pkg, Sys, Workspace } from "./argMeta";
4
+ import { getInternalArgumentValue } from "./command";
3
5
  import { command } from "./commandBuilder";
4
6
  import {
5
7
  assertUniqueDependencies,
@@ -127,6 +129,17 @@ describe("command helper metadata", () => {
127
129
  expect(commandHelp).toContain("[default: true]");
128
130
  expect(commandHelp).toContain("Fast, Full");
129
131
  });
132
+
133
+ test("resolves the only app without prompting for selection", async () => {
134
+ const workspace = new WorkspaceExecutor({ workspaceRoot: "/workspace", repoName: "repo" });
135
+ workspace.getExecs = async () => [["single-command-test-app"], [], []];
136
+
137
+ const app = await getInternalArgumentValue({ key: "", idx: 0, type: "App" }, undefined, workspace);
138
+
139
+ expect(app).toBeInstanceOf(AppExecutor);
140
+ expect(app.name).toBe("single-command-test-app");
141
+ expect(app.workspace).toBe(workspace);
142
+ });
130
143
  });
131
144
 
132
145
  describe("command helper dependency injection", () => {
package/executors.test.ts CHANGED
@@ -143,11 +143,32 @@ describe("Executor filesystem helpers", () => {
143
143
  "Akan.js Workspace Rules",
144
144
  );
145
145
  expect(await readFile(path.join(root, "workspace/AGENTS.md"), "utf8")).toContain("sample Agent Guide");
146
+ expect(await readFile(path.join(root, "workspace/docs/AI-DEVELOPMENT.md"), "utf8")).toContain(
147
+ "AI Development Guide",
148
+ );
149
+ expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
146
150
  expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
147
151
  "./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
148
152
  );
149
153
  });
150
154
 
155
+ test("applies app sample signal test helpers", async () => {
156
+ const root = await makeTempRoot();
157
+ const exec = new Executor("fixture", root);
158
+
159
+ await exec.applyTemplate({
160
+ basePath: "app",
161
+ template: "appSample",
162
+ dict: { appName: "demo" },
163
+ options: { libs: [] },
164
+ });
165
+
166
+ await expect(readFile(path.join(root, "app/lib/task/task.service.test.ts"), "utf8")).rejects.toThrow();
167
+ expect(await readFile(path.join(root, "app/lib/task/task.signal.spec.ts"), "utf8")).toContain("getCompletedTask");
168
+ expect(await readFile(path.join(root, "app/lib/task/task.signal.test.ts"), "utf8")).toContain("Task signal smoke");
169
+ expect(await readFile(path.join(root, "app/lib/task/task.document.ts"), "utf8")).toContain('action: "started"');
170
+ });
171
+
151
172
  test("copies static files from CLI templates", async () => {
152
173
  const root = await makeTempRoot();
153
174
  const exec = new Executor("fixture", root);
package/index.ts CHANGED
@@ -36,3 +36,4 @@ export * from "./types";
36
36
  export * from "./ui";
37
37
  export * from "./uploadRelease";
38
38
  export * from "./useStdoutDimensions";
39
+ export * from "./workflow";