@akanjs/devkit 3.1.1-rc.8 → 3.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +30 -0
  8. package/akanConfig/akanConfig.ts +10 -0
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/applicationBuildRunner.test.ts +17 -0
  13. package/applicationBuildRunner.ts +3 -1
  14. package/biome.base.json +4 -1
  15. package/cloud/cloudUpdates.ts +3 -2
  16. package/codeAgent/agent/CodeAgent.ts +4 -0
  17. package/codeAgent/agent/SubagentPool.ts +5 -0
  18. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  19. package/codeAgent/agent/codeAgent.test.ts +41 -2
  20. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  21. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  22. package/codeAgent/index.ts +2 -2
  23. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  24. package/codeAgent/skills/akan-code/SKILL.md +1 -0
  25. package/codeAgent/skills/akan-debug/SKILL.md +40 -6
  26. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  27. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  28. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  29. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  30. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  31. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  32. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  33. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  34. package/codeAgent/tools/AkanCli.ts +2 -1
  35. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  36. package/codeAgent/tools/AkanEditScope.ts +8 -3
  37. package/codeAgent/tools/AkanToolPack.ts +4 -10
  38. package/codeAgent/tools/AkanVerifier.ts +13 -31
  39. package/codeAgent/tools/akanCodePlugins.test.ts +60 -58
  40. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  41. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  42. package/commentDensityScanner.test.ts +121 -0
  43. package/commentDensityScanner.ts +101 -0
  44. package/componentSizeScanner.test.ts +36 -0
  45. package/componentSizeScanner.ts +52 -0
  46. package/componentWriteChainScanner.test.ts +137 -0
  47. package/componentWriteChainScanner.ts +104 -0
  48. package/conventionSet.test.ts +73 -0
  49. package/conventionSet.ts +67 -0
  50. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  51. package/deprecatedCommentScanner.test.ts +54 -0
  52. package/deprecatedCommentScanner.ts +30 -0
  53. package/endpointReach.test.ts +58 -0
  54. package/endpointReach.ts +80 -0
  55. package/executors.test.ts +0 -4
  56. package/executors.ts +18 -2
  57. package/index.ts +1 -0
  58. package/integration/devStability.integration.test.ts +9 -5
  59. package/kindBranchScanner.test.ts +89 -0
  60. package/kindBranchScanner.ts +158 -0
  61. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  62. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  63. package/lint/no-oversized-list-limit.grit +73 -0
  64. package/linter.test.ts +12 -0
  65. package/linter.ts +35 -12
  66. package/maintainabilityBaseline.test.ts +280 -0
  67. package/maintainabilityBaseline.ts +220 -0
  68. package/maintainabilityContract.test.ts +846 -0
  69. package/maintainabilityContract.ts +404 -0
  70. package/maintainabilityExceptionScanner.test.ts +90 -0
  71. package/maintainabilityExceptionScanner.ts +85 -0
  72. package/maintainabilityLimits.ts +60 -0
  73. package/maintainabilityReport.test.ts +130 -0
  74. package/maintainabilityReport.ts +153 -0
  75. package/mobile/desktopRelease.test.ts +94 -23
  76. package/mobile/desktopRelease.ts +83 -45
  77. package/mobile/nativeApi.test.ts +4 -1
  78. package/mobile/nativeApi.ts +3 -3
  79. package/mobile/nativeApp.test.ts +2 -2
  80. package/mobile/nativeApp.ts +18 -11
  81. package/mobile/nativeConfig.test.ts +16 -8
  82. package/mobile/nativeConfig.ts +1 -1
  83. package/mobile/updateUploader.test.ts +34 -0
  84. package/mobile/updateUploader.ts +9 -6
  85. package/package.json +3 -2
  86. package/platformTest/LinuxDockerTarget.ts +3 -0
  87. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  88. package/preview/PreviewView.ts +219 -0
  89. package/preview/index.ts +2 -0
  90. package/preview/previewView.test.ts +76 -0
  91. package/prompter.ts +7 -0
  92. package/qualityScanner.test.ts +75 -0
  93. package/qualityScanner.ts +17 -1
  94. package/routeSourceValidator.test.ts +11 -2
  95. package/routeSourceValidator.ts +14 -2
  96. package/serviceLoopCallScanner.test.ts +56 -0
  97. package/serviceLoopCallScanner.ts +122 -0
  98. package/signalMutationIndex.test.ts +15 -0
  99. package/signalMutationIndex.ts +1 -1
  100. package/sourceComments.test.ts +45 -0
  101. package/sourceComments.ts +70 -0
  102. package/storeAccessScanner.test.ts +84 -0
  103. package/storeAccessScanner.ts +71 -0
  104. package/storeSource.ts +159 -0
  105. package/storeWriteScanner.test.ts +292 -0
  106. package/storeWriteScanner.ts +50 -0
  107. package/testOnlyExportScanner.test.ts +83 -0
  108. package/testOnlyExportScanner.ts +124 -0
  109. package/typeChecker.ts +32 -0
  110. package/typecheck/projectTypecheck.test.ts +18 -0
  111. package/typecheck/projectTypecheck.ts +29 -5
  112. package/typecheck/typecheck.proc.ts +6 -0
  113. package/typecheck/typecheckFailure.ts +13 -0
  114. package/types.ts +1 -0
  115. package/workflow/artifacts.ts +16 -2
  116. package/workflow/executor.ts +22 -7
  117. package/workflow/guardCatalog.test.ts +59 -0
  118. package/workflow/guardCatalog.ts +108 -0
  119. package/workflow/index.ts +4 -0
  120. package/workflow/interactionScaffold.ts +122 -0
  121. package/workflow/mutationScaffold.ts +519 -0
  122. package/workflow/mutationSource.test.ts +108 -0
  123. package/workflow/mutationSource.ts +219 -0
  124. package/workflow/plan.ts +54 -0
  125. package/workflow/render.ts +19 -4
  126. package/workflow/source.test.ts +185 -0
  127. package/workflow/source.ts +151 -24
  128. package/workflow/types.ts +18 -1
  129. package/workspaceLayout.ts +2 -0
  130. package/writePath.test.ts +109 -0
  131. package/writePath.ts +219 -0
  132. package/writePlacementScanner.test.ts +77 -0
  133. package/writePlacementScanner.ts +50 -0
  134. package/writingActionIndex.test.ts +99 -0
  135. package/writingActionIndex.ts +63 -0
  136. package/codeAgent/feedback/PreviewView.ts +0 -95
  137. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -41,6 +41,7 @@ export const moduleSourcePaths = (moduleName: string) => {
41
41
  abstract: `lib/${moduleName}/${moduleName}.abstract.md`,
42
42
  constant: `lib/${moduleName}/${moduleName}.constant.ts`,
43
43
  dictionary: `lib/${moduleName}/${moduleName}.dictionary.ts`,
44
+ document: `lib/${moduleName}/${moduleName}.document.ts`,
44
45
  service: `lib/${moduleName}/${moduleName}.service.ts`,
45
46
  signal: `lib/${moduleName}/${moduleName}.signal.ts`,
46
47
  store: `lib/${moduleName}/${moduleName}.store.ts`,
@@ -57,14 +58,12 @@ export const generatedFilesForSync = (sys: Sys, reason = "Generated files may ch
57
58
 
58
59
  export const validationCommandsForTarget = (target: string) =>
59
60
  [
60
- { command: `akan sync ${target}`, reason: "Refresh generated Akan files from source conventions." },
61
- { command: `akan lint ${target}`, reason: "Validate formatting, imports, and static lint rules." },
61
+ { command: `akan verify ${target}`, reason: "Sync, lint, typecheck and build the target in one run." },
62
62
  ] satisfies PrimitiveValidationCommand[];
63
63
 
64
64
  export const nextActionsForTarget = (target: string) =>
65
65
  [
66
- { command: `akan sync ${target}`, reason: "Refresh generated Akan files after source changes." },
67
- { command: `akan lint ${target}`, reason: "Validate the target after generated files are refreshed." },
66
+ { command: `akan verify ${target}`, reason: "Refresh generated files and validate the target in one run." },
68
67
  ] satisfies PrimitiveNextAction[];
69
68
 
70
69
  export const createPrimitiveWriteReport = ({
@@ -189,20 +188,52 @@ export const normalizeFieldType = (typeName: string) => {
189
188
  return normalizedTypes[typeName.toLowerCase()] ?? typeName;
190
189
  };
191
190
 
191
+ //? Biome's organizeImports order: case-insensitive, an uppercase name before its lowercase twin.
192
+ const importOrder = (a: string, b: string) => {
193
+ const [lowerA, lowerB] = [a.toLowerCase(), b.toLowerCase()];
194
+ if (lowerA !== lowerB) return lowerA < lowerB ? -1 : 1;
195
+ return a < b ? -1 : a > b ? 1 : 0;
196
+ };
197
+
192
198
  export const ensureNamedImport = (content: string, moduleSpecifier: string, name: string) => {
193
199
  const source = sourceFileFor("source.ts", content);
194
- const namedImport = findNamedImport(source, moduleSpecifier);
195
- if (namedImport) {
196
- if (namedImport.names.includes(name)) return content;
197
- const nextNames = [...namedImport.names, name].sort();
198
- return spliceText(
199
- content,
200
- namedImport.namedBindingsStart,
201
- namedImport.namedBindingsEnd,
202
- `{ ${nextNames.join(", ")} }`,
200
+ const namedImport = findNamedImport(source, moduleSpecifier, { valueOnly: true });
201
+ if (!namedImport) return ensureImportDeclaration(content, moduleSpecifier, `{ ${name} }`);
202
+ if (namedImport.names.includes(name)) return content;
203
+ const nextElements = [...namedImport.elements, { name, text: name }].sort((a, b) => importOrder(a.name, b.name));
204
+ return spliceText(
205
+ content,
206
+ namedImport.namedBindingsStart,
207
+ namedImport.namedBindingsEnd,
208
+ `{ ${nextElements.map((element) => element.text).join(", ")} }`,
209
+ );
210
+ };
211
+
212
+ export const ensureImportDeclaration = (content: string, moduleSpecifier: string, clause: string) => {
213
+ const line = `import ${clause} from "${moduleSpecifier}";`;
214
+ if (content.includes(line)) return content;
215
+ const source = sourceFileFor("source.ts", content);
216
+ const imports = source.statements
217
+ .filter((statement): statement is ts.ImportDeclaration => ts.isImportDeclaration(statement))
218
+ .flatMap((statement) =>
219
+ ts.isStringLiteral(statement.moduleSpecifier)
220
+ ? [{ specifier: statement.moduleSpecifier.text, start: statement.getStart(source), end: statement.getEnd() }]
221
+ : [],
203
222
  );
223
+ const isRelative = (specifier: string) => specifier.startsWith(".");
224
+ const peers = imports.filter((entry) => isRelative(entry.specifier) === isRelative(moduleSpecifier));
225
+ const before = peers.find((entry) => importOrder(entry.specifier, moduleSpecifier) > 0);
226
+ if (before) {
227
+ const insertAt = lineStartAt(content, before.start);
228
+ return spliceText(content, insertAt, insertAt, `${line}\n`);
204
229
  }
205
- return `import { ${name} } from "${moduleSpecifier}";\n${content}`;
230
+ const after = peers.at(-1) ?? (isRelative(moduleSpecifier) ? imports.at(-1) : undefined);
231
+ if (after) {
232
+ const insertAt = lineEndAt(content, after.end);
233
+ return spliceText(content, insertAt, insertAt, peers.length ? `${line}\n` : `\n${line}\n`);
234
+ }
235
+ const insertAt = imports[0] ? lineStartAt(content, imports[0].start) : 0;
236
+ return spliceText(content, insertAt, insertAt, `${line}\n\n`);
206
237
  };
207
238
 
208
239
  export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
@@ -382,6 +413,7 @@ interface ArrayInsertionLocator {
382
413
 
383
414
  interface NamedImportLocator {
384
415
  names: string[];
416
+ elements: { name: string; text: string }[];
385
417
  namedBindingsStart: number;
386
418
  namedBindingsEnd: number;
387
419
  }
@@ -499,16 +531,23 @@ const dictionaryChainOrderValid = (chainMethods: readonly string[]) => {
499
531
  return true;
500
532
  };
501
533
 
502
- const findDictionaryModelObject = (source: ts.SourceFile, moduleClassName: string): DictionaryModelLocator | null => {
534
+ const findDictionaryModelObject = (source: ts.SourceFile, moduleClassName: string) =>
535
+ findDictionaryStageObject(source, "model", moduleClassName);
536
+
537
+ const findDictionaryStageObject = (
538
+ source: ts.SourceFile,
539
+ stage: string,
540
+ typeName: string,
541
+ ): DictionaryModelLocator | null => {
503
542
  let locator: DictionaryModelLocator | null = null;
504
543
  const visit = (node: ts.Node) => {
505
544
  if (locator) return;
506
- if (!ts.isCallExpression(node) || callExpressionName(node) !== "model") {
545
+ if (!ts.isCallExpression(node) || callExpressionName(node) !== stage) {
507
546
  ts.forEachChild(node, visit);
508
547
  return;
509
548
  }
510
549
  const typeArgument = node.typeArguments?.[0];
511
- if (!typeArgument || typeArgument.getText(source) !== moduleClassName) {
550
+ if (!typeArgument || typeArgument.getText(source) !== typeName) {
512
551
  ts.forEachChild(node, visit);
513
552
  return;
514
553
  }
@@ -556,14 +595,20 @@ const findLightProjectionArray = (source: ts.SourceFile, moduleClassName: string
556
595
  return locator;
557
596
  };
558
597
 
559
- const findNamedImport = (source: ts.SourceFile, moduleSpecifier: string): NamedImportLocator | null => {
598
+ const findNamedImport = (
599
+ source: ts.SourceFile,
600
+ moduleSpecifier: string,
601
+ { valueOnly = false }: { valueOnly?: boolean } = {},
602
+ ): NamedImportLocator | null => {
560
603
  for (const statement of source.statements) {
561
604
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
562
605
  if (statement.moduleSpecifier.text !== moduleSpecifier) continue;
606
+ if (valueOnly && statement.importClause?.isTypeOnly) continue;
563
607
  const namedBindings = statement.importClause?.namedBindings;
564
608
  if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
565
609
  return {
566
610
  names: namedBindings.elements.map((element) => element.name.text),
611
+ elements: namedBindings.elements.map((element) => ({ name: element.name.text, text: element.getText(source) })),
567
612
  namedBindingsStart: namedBindings.getStart(source),
568
613
  namedBindingsEnd: namedBindings.getEnd(),
569
614
  };
@@ -816,6 +861,27 @@ export const hasDictionaryModelField = (content: string, moduleClassName: string
816
861
  return findDictionaryModelObject(source, moduleClassName)?.fields.some((field) => field.name === fieldName) ?? false;
817
862
  };
818
863
 
864
+ export const hasDictionaryEndpointEntry = (content: string, moduleClassName: string, entryName: string) => {
865
+ const source = sourceFileFor("dictionary.ts", content);
866
+ return (
867
+ findDictionaryStageObject(source, "endpoint", `${moduleClassName}Endpoint`)?.fields.some(
868
+ (field) => field.name === entryName,
869
+ ) ?? false
870
+ );
871
+ };
872
+
873
+ export const insertDictionaryEndpointEntry = (
874
+ content: string,
875
+ moduleClassName: string,
876
+ entryName: string,
877
+ entryText: string,
878
+ ) => {
879
+ const source = sourceFileFor("dictionary.ts", content);
880
+ const locator = findDictionaryStageObject(source, "endpoint", `${moduleClassName}Endpoint`);
881
+ if (!locator) return null;
882
+ return insertOrderedFieldLine(content, locator, entryName, entryText, { fieldIndent: " ", closingIndent: " " });
883
+ };
884
+
819
885
  export const ensureConstantTypeImport = (content: string, constantPath: string, typeName: string) => {
820
886
  if (new RegExp(`import type \\{[^}]*\\b${typeName}\\b[^}]*\\} from "${constantPath}";`).test(content)) return content;
821
887
  const importPattern = new RegExp(`import type \\{ ([^}]+) \\} from "${constantPath}";`);
@@ -830,17 +896,38 @@ export const ensureConstantTypeImport = (content: string, constantPath: string,
830
896
  return `import type { ${typeName} } from "${constantPath}";\n${content}`;
831
897
  };
832
898
 
899
+ const dictionaryChainOf = (node: ts.Node): ts.CallExpression | undefined => {
900
+ if (
901
+ ts.isCallExpression(node) &&
902
+ outermostFluentCall(node) === node &&
903
+ protectedDictionaryChainOrder.some((method) => chainMethodsForCall(node).includes(method))
904
+ )
905
+ return node;
906
+ return ts.forEachChild(node, dictionaryChainOf);
907
+ };
908
+
909
+ const chainMethodNames = (call: ts.Expression): ts.Identifier[] => {
910
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return [];
911
+ const name = call.expression.name;
912
+ return [...chainMethodNames(call.expression.expression), ...(ts.isIdentifier(name) ? [name] : [])];
913
+ };
914
+
915
+ //* Located on the parsed chain, never by text search: the sample dictionary's header comment mentions `.error()`.
833
916
  export const insertDictionaryEnum = (content: string, enumClassName: string, enumName: string, values: string[]) => {
834
917
  if (content.includes(`.enum<${enumClassName}>("${enumName}"`)) return content;
918
+ const source = sourceFileFor("dictionary.ts", content);
919
+ if (hasParseDiagnostics(source)) return null;
920
+ const chain = dictionaryChainOf(source);
921
+ if (!chain) return null;
835
922
  const enumBlock = ` .enum<${enumClassName}>("${enumName}", (t) => ({\n${values
836
923
  .map((value) => ` ${value}: t([${JSON.stringify(titleize(value))}, ${JSON.stringify(titleize(value))}]),`)
837
924
  .join("\n")}\n }))\n`;
838
- const chainEndIndex = content.lastIndexOf(";");
839
- const insertBeforeIndex = [content.indexOf(".error("), content.indexOf(".translate("), chainEndIndex]
840
- .filter((index) => index >= 0)
841
- .sort((a, b) => a - b)[0];
842
- if (insertBeforeIndex === undefined) return null;
843
- return `${content.slice(0, insertBeforeIndex)}${enumBlock}${content.slice(insertBeforeIndex)}`;
925
+ const later = chainMethodNames(chain).find((name) => name.text === "error" || name.text === "translate");
926
+ if (!later) return spliceText(content, chain.getEnd(), chain.getEnd(), `\n${enumBlock.trimEnd()}`);
927
+ const dot = content.lastIndexOf(".", later.getStart(source));
928
+ const lineStart = lineStartAt(content, dot);
929
+ if (content.slice(lineStart, dot).trim() === "") return spliceText(content, lineStart, lineStart, enumBlock);
930
+ return spliceText(content, dot, dot, `\n${enumBlock}`);
844
931
  };
845
932
 
846
933
  export const parseValues = (value: string | null) =>
@@ -883,6 +970,19 @@ export const hasClassMethod = (content: string, className: string, methodName: s
883
970
  );
884
971
  };
885
972
 
973
+ export const classMethodParameters = (content: string, className: string, methodName: string) => {
974
+ const source = sourceFileFor("service.ts", content);
975
+ const method = findClassDeclaration(source, className)?.members.find(
976
+ (member): member is ts.MethodDeclaration => ts.isMethodDeclaration(member) && nodeName(member.name) === methodName,
977
+ );
978
+ return (
979
+ method?.parameters.map((parameter) => ({
980
+ name: nodeName(parameter.name),
981
+ optional: !!parameter.questionToken || !!parameter.initializer || !!parameter.dotDotDotToken,
982
+ })) ?? null
983
+ );
984
+ };
985
+
886
986
  export const insertClassMethod = (content: string, className: string, methodBlock: string): string | null => {
887
987
  const source = sourceFileFor("service.ts", content);
888
988
  const node = findClassDeclaration(source, className);
@@ -976,3 +1076,30 @@ export const insertSignalFactoryEntry = (
976
1076
  if (withEntry === content) return null;
977
1077
  return paramEdit === "unchanged" ? withEntry : spliceText(withEntry, paramEdit.start, paramEdit.end, paramEdit.text);
978
1078
  };
1079
+
1080
+ export const appendTopLevelTestCases = (
1081
+ content: string,
1082
+ describeTitle: string,
1083
+ render: (itName: string, indent: string) => string[],
1084
+ ) => {
1085
+ const bunImport = findNamedImport(sourceFileFor("test.ts", content), "bun:test", { valueOnly: true });
1086
+ const itName = !bunImport?.names.includes("it") && bunImport?.names.includes("test") ? "test" : "it";
1087
+ const imported = ensureNamedImport(content, "bun:test", itName);
1088
+ const source = sourceFileFor("test.ts", imported);
1089
+ const body = source.statements.flatMap((statement) => {
1090
+ if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return [];
1091
+ if (expressionName(statement.expression.expression) !== "describe") return [];
1092
+ const callback = factoryArrowOf(statement.expression);
1093
+ return callback && ts.isBlock(callback.body) ? [callback.body] : [];
1094
+ })[0];
1095
+ if (!body) {
1096
+ const block = [`describe(${JSON.stringify(describeTitle)}, () => {`, ...render(itName, " "), "});", ""];
1097
+ return `${ensureNamedImport(imported, "bun:test", "describe").trimEnd()}\n\n${block.join("\n")}`;
1098
+ }
1099
+ const firstStatement = body.statements[0];
1100
+ const indent = firstStatement
1101
+ ? lineIndentAt(imported, firstStatement.getStart(source))
1102
+ : `${lineIndentAt(imported, body.getStart(source))} `;
1103
+ const insertAt = lineStartAt(imported, body.getEnd() - 1);
1104
+ return spliceText(imported, insertAt, insertAt, `${render(itName, indent).join("\n")}\n`);
1105
+ };
package/workflow/types.ts CHANGED
@@ -8,7 +8,7 @@ export type WorkflowFormat = "markdown" | "json";
8
8
  export type WorkflowPlanInputs = Record<string, string | null>;
9
9
  export type PrimitiveFormat = "markdown" | "json";
10
10
  export type UiSurface = "view" | "unit" | "template";
11
- export type WorkflowValidationKind = "sync" | "lint" | "typecheck" | "doctor" | "custom";
11
+ export type WorkflowValidationKind = "verify" | "sync" | "lint" | "typecheck" | "doctor" | "custom";
12
12
  export type WorkflowFailureScope = "workspace-config" | "environment" | "source-change" | "unknown";
13
13
  export type WorkflowValidationStatus = "passed" | "failed" | "unknown";
14
14
  export interface WorkflowValidationSummary {
@@ -49,6 +49,14 @@ export interface AddEnumFieldInput extends PrimitiveTargetInput {
49
49
 
50
50
  export interface AddMutationInput extends PrimitiveTargetInput {
51
51
  mutation: string | null;
52
+ guards?: string[] | null;
53
+ withCalls?: string[] | null;
54
+ afterCalls?: string[] | null;
55
+ }
56
+
57
+ export interface CreateInteractionInput {
58
+ app: string | null;
59
+ interaction: string | null;
52
60
  }
53
61
 
54
62
  export interface AddSliceInput extends PrimitiveTargetInput {
@@ -94,6 +102,7 @@ export interface WorkflowSpec {
94
102
  predictedChanges: readonly WorkflowPredictedChange[];
95
103
  validation: readonly WorkflowValidation[];
96
104
  completionCriteria: readonly string[];
105
+ replacesRules?: readonly string[];
97
106
  }
98
107
 
99
108
  export interface WorkflowDiagnostic {
@@ -382,6 +391,13 @@ export interface PrimitiveWriteReport {
382
391
  nextActions: PrimitiveNextAction[];
383
392
  }
384
393
 
394
+ export interface ScaffoldFile {
395
+ path: string;
396
+ content: string;
397
+ action: "create" | "modify";
398
+ reason: string;
399
+ }
400
+
385
401
  export type PrimitiveFileMap = Record<string, { filename: string; content: string }>;
386
402
 
387
403
  export interface WorkflowApplyReport {
@@ -482,4 +498,5 @@ export interface WorkflowPrimitiveOperations {
482
498
  addEnumField: (input: AddEnumFieldInput) => Promise<PrimitiveWriteReport>;
483
499
  addMutation: (input: AddMutationInput) => Promise<PrimitiveWriteReport>;
484
500
  addSlice: (input: AddSliceInput) => Promise<PrimitiveWriteReport>;
501
+ createInteraction: (input: CreateInteractionInput) => Promise<PrimitiveWriteReport>;
485
502
  }
@@ -7,6 +7,7 @@ export const appRootAllowedFiles = new Set([
7
7
  "AGENTS.md",
8
8
  "CLAUDE.md",
9
9
  "akan.app.json",
10
+ "akan.baseline.json",
10
11
  "akan.config.ts",
11
12
  "client.ts",
12
13
  "main.ts",
@@ -38,6 +39,7 @@ export const libRootAllowedFiles = new Set([
38
39
  "AGENTS.md",
39
40
  "CLAUDE.md",
40
41
  "README.md",
42
+ "akan.baseline.json",
41
43
  "akan.config.ts",
42
44
  "akan.lib.json",
43
45
  "client.ts",
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { WritePath } from "./writePath";
4
+
5
+ const pathIn = (body: string, enters?: (fn: ts.SignatureDeclaration) => boolean) => {
6
+ const sourceFile = ts.createSourceFile("a.ts", `async function run() {\n${body}\n}`, ts.ScriptTarget.Latest, true);
7
+ const fn = sourceFile.statements[0];
8
+ if (!fn || !ts.isFunctionDeclaration(fn) || !fn.body) throw new Error("fixture has no function body");
9
+ const isWrite = (call: ts.CallExpression) => /^write/.test(call.expression.getText(sourceFile));
10
+ const path = new WritePath(isWrite, { enters });
11
+ const calls = path.along(fn.body);
12
+ return { labels: calls.map((call) => path.labelOf(call, sourceFile)), count: path.countOf(calls) };
13
+ };
14
+ const writesIn = (body: string, enters?: (fn: ts.SignatureDeclaration) => boolean) =>
15
+ pathIn(body, enters).labels.map((label) => label.replace(" (per item)", ""));
16
+
17
+ describe("WritePath", () => {
18
+ test("adds up writes in sequence, nested callbacks and logical operands included", () => {
19
+ expect(
20
+ writesIn("await writeA();\nawait Promise.all(ids.map((id) => writeB(id)));\nok && (await writeC());"),
21
+ ).toEqual(["writeA", "writeB", "writeC"]);
22
+ });
23
+
24
+ test("counts only the busiest of exclusive branches", () => {
25
+ expect(writesIn("if (x) await writeA();\nelse await writeB();")).toEqual(["writeA"]);
26
+ expect(writesIn("await (x ? writeA() : Promise.all([writeB(), writeC()]));")).toEqual(["writeB", "writeC"]);
27
+ expect(
28
+ writesIn(
29
+ "switch (x) {\n case 1:\n await writeA();\n break;\n default:\n await writeB();\n await writeC();\n}",
30
+ ),
31
+ ).toEqual(["writeB", "writeC"]);
32
+ });
33
+
34
+ test("reads an if that returns as the else of the statements after it", () => {
35
+ expect(
36
+ writesIn("await writeA();\nif (x) {\n await writeB();\n return;\n}\nawait writeC();\nawait writeD();"),
37
+ ).toEqual(["writeA", "writeC", "writeD"]);
38
+ expect(writesIn("if (x) await writeB();\nawait writeC();")).toEqual(["writeB", "writeC"]);
39
+ });
40
+
41
+ test("reads every branch that returns as exclusive of the statements after it, however it is nested", () => {
42
+ const countIn = (body: string) => pathIn(body).count;
43
+ expect(
44
+ countIn(
45
+ "if (x) {\n await writeA();\n return;\n} else if (y) {\n await writeB();\n return;\n}\nawait writeC();",
46
+ ),
47
+ ).toBe(1);
48
+ expect(countIn('switch (x) {\n case "a":\n await writeA();\n return;\n}\nawait writeC();')).toBe(1);
49
+ expect(countIn("if (x) {\n if (y) {\n await writeA();\n return;\n }\n}\nawait writeC();")).toBe(1);
50
+ expect(writesIn("try {\n await writeA();\n return;\n} catch {\n await writeB();\n}")).toEqual([
51
+ "writeA",
52
+ "writeB",
53
+ ]);
54
+ });
55
+
56
+ test("runs a switch clause without break on into the next one", () => {
57
+ expect(
58
+ writesIn("switch (x) {\n case 1:\n await writeA();\n case 2:\n await writeB();\n break;\n}"),
59
+ ).toEqual(["writeA", "writeB"]);
60
+ });
61
+
62
+ test("counts a write in a loop or a per-element callback as two, but not the list it loops over", () => {
63
+ expect(pathIn("for (const id of ids) await writeA(id);")).toEqual({ labels: ["writeA (per item)"], count: 2 });
64
+ expect(pathIn("for (const id of await writeA()) read(id);")).toEqual({ labels: ["writeA"], count: 1 });
65
+ expect(pathIn("while (await more()) await writeA();")).toEqual({ labels: ["writeA (per item)"], count: 2 });
66
+ expect(pathIn("await Promise.all(ids.map((id) => writeA(id)));")).toEqual({
67
+ labels: ["writeA (per item)"],
68
+ count: 2,
69
+ });
70
+ expect(pathIn("await read().then(() => writeA());")).toEqual({ labels: ["writeA"], count: 1 });
71
+ expect(pathIn("for (let i = 0; i < ids.length; i++) await writeA(ids[i]);")).toEqual({
72
+ labels: ["writeA (per item)"],
73
+ count: 2,
74
+ });
75
+ expect(pathIn("while (queue.length) await writeA(queue.shift());")).toEqual({
76
+ labels: ["writeA (per item)"],
77
+ count: 2,
78
+ });
79
+ });
80
+
81
+ test("counts a retry or a poll as one write, and a loop that returns on its write as one", () => {
82
+ expect(pathIn("for (let i = 0; i < 3; i++) {\n try {\n await writeA();\n return;\n } catch {}\n}")).toEqual(
83
+ {
84
+ labels: ["writeA"],
85
+ count: 1,
86
+ },
87
+ );
88
+ expect(pathIn("let ok = false;\nwhile (!ok) ok = await writeA();")).toEqual({ labels: ["writeA"], count: 1 });
89
+ expect(pathIn("for (const id of ids)\n if (id) {\n await writeA(id);\n return;\n }")).toEqual({
90
+ labels: ["writeA"],
91
+ count: 1,
92
+ });
93
+ });
94
+
95
+ test("weighs a branch writing per item above one writing once", () => {
96
+ expect(pathIn("if (x) await writeA();\nelse for (const id of ids) await writeB(id);")).toEqual({
97
+ labels: ["writeB (per item)"],
98
+ count: 2,
99
+ });
100
+ });
101
+
102
+ test("leaves out a nested function the caller does not enter", () => {
103
+ const body = "await writeA();\nids.map((id) => writeB(id));\nsetTimeout(() => writeC(), 0);";
104
+ const entersMapOnly = (fn: ts.SignatureDeclaration) =>
105
+ ts.isCallExpression(fn.parent) && fn.parent.expression.getText().endsWith(".map");
106
+
107
+ expect(writesIn(body, entersMapOnly)).toEqual(["writeA", "writeB"]);
108
+ });
109
+ });
package/writePath.ts ADDED
@@ -0,0 +1,219 @@
1
+ import ts from "typescript";
2
+
3
+ interface Flow {
4
+ through: ts.CallExpression[] | null;
5
+ exit: ts.CallExpression[] | null;
6
+ jump: ts.CallExpression[] | null;
7
+ }
8
+
9
+ //? Exclusive branches never run together, so only the busiest one counts, a branch that returns included; a write in
10
+ //? a loop over items or a per-element callback runs once per item, so it counts as two.
11
+ export class WritePath {
12
+ static readonly #perElement = new Set([
13
+ "map",
14
+ "flatMap",
15
+ "forEach",
16
+ "filter",
17
+ "reduce",
18
+ "some",
19
+ "every",
20
+ "find",
21
+ "findIndex",
22
+ ]);
23
+ readonly #matches: (call: ts.CallExpression) => boolean;
24
+ readonly #enters: (fn: ts.SignatureDeclaration) => boolean;
25
+ readonly #repeated = new WeakSet<ts.CallExpression>();
26
+
27
+ constructor(
28
+ matches: (call: ts.CallExpression) => boolean,
29
+ { enters = () => true }: { enters?: (fn: ts.SignatureDeclaration) => boolean } = {},
30
+ ) {
31
+ this.#matches = matches;
32
+ this.#enters = enters;
33
+ }
34
+
35
+ along(node: ts.Node): ts.CallExpression[] {
36
+ if (ts.isFunctionLike(node) && !this.#enters(node)) return [];
37
+ if (WritePath.isPerElementCallback(node)) return this.#repeat(this.#children(node));
38
+ if (WritePath.#isFlowStatement(node)) return this.#busiestOf(this.#flowOf(node));
39
+ if (ts.isConditionalExpression(node))
40
+ return [...this.along(node.condition), ...this.#busier(this.along(node.whenTrue), this.along(node.whenFalse))];
41
+ const own = ts.isCallExpression(node) && this.#matches(node) ? [node] : [];
42
+ return [...own, ...this.#children(node)];
43
+ }
44
+
45
+ isRepeated(call: ts.CallExpression) {
46
+ return this.#repeated.has(call);
47
+ }
48
+
49
+ countOf(calls: ts.CallExpression[]) {
50
+ return calls.reduce((count, call) => count + (this.isRepeated(call) ? 2 : 1), 0);
51
+ }
52
+
53
+ labelOf(call: ts.CallExpression, sourceFile: ts.SourceFile) {
54
+ const callee = call.expression.getText(sourceFile);
55
+ return this.isRepeated(call) ? `${callee} (per item)` : callee;
56
+ }
57
+
58
+ static isPerElementCallback(node: ts.Node) {
59
+ const call = node.parent;
60
+ return (
61
+ (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
62
+ !!call &&
63
+ ts.isCallExpression(call) &&
64
+ call.arguments.some((arg) => arg === node) &&
65
+ ts.isPropertyAccessExpression(call.expression) &&
66
+ WritePath.#perElement.has(call.expression.name.text)
67
+ );
68
+ }
69
+
70
+ #children(node: ts.Node) {
71
+ const children: ts.Node[] = [];
72
+ ts.forEachChild(node, (child) => {
73
+ children.push(child);
74
+ });
75
+ return children.flatMap((child) => this.along(child));
76
+ }
77
+
78
+ #repeat(calls: ts.CallExpression[]) {
79
+ for (const call of calls) this.#repeated.add(call);
80
+ return calls;
81
+ }
82
+
83
+ #flowOf(node: ts.Statement): Flow {
84
+ if (ts.isBlock(node)) return this.#sequenceOf(node.statements);
85
+ if (ts.isReturnStatement(node) || ts.isThrowStatement(node))
86
+ return { through: null, exit: node.expression ? this.along(node.expression) : [], jump: null };
87
+ if (ts.isBreakStatement(node) || ts.isContinueStatement(node)) return { through: null, exit: null, jump: [] };
88
+ if (ts.isLabeledStatement(node)) return this.#flowOf(node.statement);
89
+ if (ts.isIfStatement(node))
90
+ return WritePath.#prefixed(
91
+ this.along(node.expression),
92
+ this.#merged(
93
+ this.#flowOf(node.thenStatement),
94
+ node.elseStatement ? this.#flowOf(node.elseStatement) : WritePath.#onward([]),
95
+ ),
96
+ );
97
+ if (ts.isSwitchStatement(node)) return WritePath.#prefixed(this.along(node.expression), this.#switchOf(node));
98
+ if (ts.isTryStatement(node)) return this.#tryOf(node);
99
+ if (ts.isForOfStatement(node) || ts.isForInStatement(node))
100
+ return this.#loopOf(this.along(node.expression), [], node.statement, true);
101
+ if (ts.isForStatement(node))
102
+ return this.#loopOf(
103
+ node.initializer ? this.along(node.initializer) : [],
104
+ [node.condition, node.incrementor].flatMap((part) => (part ? this.along(part) : [])),
105
+ node.statement,
106
+ WritePath.#walksItems(node.condition),
107
+ );
108
+ if (ts.isWhileStatement(node) || ts.isDoStatement(node))
109
+ return this.#loopOf([], this.along(node.expression), node.statement, WritePath.#walksItems(node.expression));
110
+ return WritePath.#onward(this.along(node));
111
+ }
112
+
113
+ #sequenceOf(statements: readonly ts.Statement[]): Flow {
114
+ let through: ts.CallExpression[] | null = [];
115
+ let exit: ts.CallExpression[] | null = null;
116
+ let jump: ts.CallExpression[] | null = null;
117
+ for (const statement of statements) {
118
+ if (through === null) break;
119
+ const flow = this.#flowOf(statement);
120
+ if (flow.exit) exit = this.#busierOf(exit, [...through, ...flow.exit]);
121
+ if (flow.jump) jump = this.#busierOf(jump, [...through, ...flow.jump]);
122
+ through = flow.through && [...through, ...flow.through];
123
+ }
124
+ return { through, exit, jump };
125
+ }
126
+
127
+ //? A clause without `break` runs on into the next one; `break` leaves the switch and goes on after it.
128
+ #switchOf(node: ts.SwitchStatement): Flow {
129
+ const hasDefault = node.caseBlock.clauses.some(ts.isDefaultClause);
130
+ let flow: Flow = { through: hasDefault ? null : [], exit: null, jump: null };
131
+ let fallen: ts.CallExpression[] | null = null;
132
+ for (const clause of node.caseBlock.clauses) {
133
+ const entry = fallen && this.countOf(fallen) > 0 ? fallen : [];
134
+ const own = WritePath.#prefixed(entry, this.#sequenceOf(clause.statements));
135
+ flow = this.#merged(flow, { through: own.jump, exit: own.exit, jump: null });
136
+ fallen = own.through;
137
+ }
138
+ return this.#merged(flow, { through: fallen, exit: null, jump: null });
139
+ }
140
+
141
+ //? A catch runs after whatever the try sent before it threw, so both count; a finally follows every path.
142
+ #tryOf(node: ts.TryStatement): Flow {
143
+ const tried = this.#flowOf(node.tryBlock);
144
+ const sent = this.#busiestOf(tried);
145
+ const caught = node.catchClause
146
+ ? WritePath.#prefixed(sent, this.#flowOf(node.catchClause.block))
147
+ : { through: null, exit: sent, jump: null };
148
+ const flow = this.#merged(tried, caught);
149
+ if (!node.finallyBlock) return flow;
150
+ const after = this.#busiestOf(this.#flowOf(node.finallyBlock));
151
+ return {
152
+ through: flow.through && [...flow.through, ...after],
153
+ exit: flow.exit && [...flow.exit, ...after],
154
+ jump: flow.jump && [...flow.jump, ...after],
155
+ };
156
+ }
157
+
158
+ //? An iteration that leaves the function sends its writes once; one that goes on repeats them for a loop over items.
159
+ #loopOf(once: ts.CallExpression[], everyTurn: ts.CallExpression[], body: ts.Statement, isPerItem: boolean): Flow {
160
+ const flow = this.#flowOf(body);
161
+ const turn = [...everyTurn, ...(this.#busierOf(flow.through, flow.jump) ?? [])];
162
+ const repeated = isPerItem ? this.#repeat(turn) : turn;
163
+ return { through: [...once, ...repeated], exit: flow.exit && [...once, ...flow.exit], jump: null };
164
+ }
165
+
166
+ #merged(a: Flow, b: Flow): Flow {
167
+ return {
168
+ through: this.#busierOf(a.through, b.through),
169
+ exit: this.#busierOf(a.exit, b.exit),
170
+ jump: this.#busierOf(a.jump, b.jump),
171
+ };
172
+ }
173
+
174
+ #busiestOf({ through, exit, jump }: Flow) {
175
+ return this.#busierOf(this.#busierOf(through, exit), jump) ?? [];
176
+ }
177
+
178
+ #busierOf(a: ts.CallExpression[] | null, b: ts.CallExpression[] | null) {
179
+ return a && b ? this.#busier(a, b) : (a ?? b);
180
+ }
181
+
182
+ static #onward(calls: ts.CallExpression[]): Flow {
183
+ return { through: calls, exit: null, jump: null };
184
+ }
185
+
186
+ static #prefixed(calls: ts.CallExpression[], { through, exit, jump }: Flow): Flow {
187
+ return {
188
+ through: through && [...calls, ...through],
189
+ exit: exit && [...calls, ...exit],
190
+ jump: jump && [...calls, ...jump],
191
+ };
192
+ }
193
+
194
+ static #isFlowStatement(node: ts.Node): node is ts.Statement {
195
+ return (
196
+ ts.isBlock(node) ||
197
+ ts.isIfStatement(node) ||
198
+ ts.isSwitchStatement(node) ||
199
+ ts.isTryStatement(node) ||
200
+ ts.isLabeledStatement(node) ||
201
+ ts.isReturnStatement(node) ||
202
+ ts.isThrowStatement(node) ||
203
+ ts.isBreakStatement(node) ||
204
+ ts.isContinueStatement(node) ||
205
+ ts.isIterationStatement(node, false)
206
+ );
207
+ }
208
+
209
+ //? A loop bound by `xs.length` or asking a call for more (`while (await next())`) walks items; a bound it counts
210
+ //? itself (`i < 3`, `!ok`) is a retry or a poll, which sends one write however many times it tries.
211
+ static #walksItems(node: ts.Node | undefined): boolean {
212
+ if (!node) return false;
213
+ if (ts.isCallExpression(node)) return true;
214
+ if (ts.isPropertyAccessExpression(node) && ["length", "size"].includes(node.name.text)) return true;
215
+ return !!ts.forEachChild(node, (child) => WritePath.#walksItems(child) || undefined);
216
+ }
217
+
218
+ #busier = (a: ts.CallExpression[], b: ts.CallExpression[]) => (this.countOf(b) > this.countOf(a) ? b : a);
219
+ }