@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20

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 (233) hide show
  1. package/abstractDoc.test.ts +6 -15
  2. package/abstractDoc.ts +1 -4
  3. package/agentsIndex.test.ts +4 -7
  4. package/agentsIndex.ts +6 -22
  5. package/akanApp/BackendImportGraph.test.ts +8 -22
  6. package/akanApp/BackendImportGraph.ts +1 -20
  7. package/akanApp/akanApp.host.test.ts +1 -3
  8. package/akanApp/akanApp.host.ts +64 -250
  9. package/akanApp/devHostPolicy.test.ts +10 -34
  10. package/akanApp/devHostPolicy.ts +12 -98
  11. package/akanConfig/akanConfig.test.ts +30 -39
  12. package/akanConfig/akanConfig.ts +40 -136
  13. package/akanContext.ts +106 -159
  14. package/akanMcpContract.ts +28 -73
  15. package/appSelectionMemory.ts +1 -4
  16. package/applicationBuildReporter.ts +37 -5
  17. package/applicationBuildRunner.ts +25 -59
  18. package/applicationReleasePackager.ts +24 -44
  19. package/applicationTestPreload.ts +3 -12
  20. package/artifact/implicitRootLayout.test.ts +3 -19
  21. package/artifact/implicitRootLayout.ts +34 -91
  22. package/artifact/routeSeedIndex.ts +11 -44
  23. package/biomeBase.ts +2 -5
  24. package/capacitorApp.test.ts +4 -14
  25. package/capacitorApp.ts +86 -235
  26. package/cloud/cloudApi.test.ts +202 -0
  27. package/cloud/cloudApi.ts +33 -57
  28. package/cloud/configLock.test.ts +64 -0
  29. package/cloud/configLock.ts +96 -0
  30. package/cloud/constants.ts +3 -2
  31. package/cloud/globalConfig.ts +64 -31
  32. package/codeAgent/agent/AkanCodeServices.ts +15 -57
  33. package/codeAgent/agent/AkanContextFiles.ts +5 -28
  34. package/codeAgent/agent/CodeAgent.ts +33 -108
  35. package/codeAgent/agent/CodeAgentAsks.ts +7 -30
  36. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
  37. package/codeAgent/agent/CodeAgentGate.ts +8 -34
  38. package/codeAgent/agent/CodeAgentProxy.ts +4 -13
  39. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
  40. package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
  41. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
  42. package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
  43. package/codeAgent/agent/CodeAgentUi.ts +2 -7
  44. package/codeAgent/agent/CodeMailbox.ts +7 -34
  45. package/codeAgent/agent/CodeSessionFork.ts +4 -19
  46. package/codeAgent/agent/CodeSessionIndex.ts +5 -31
  47. package/codeAgent/agent/SubagentPool.ts +48 -58
  48. package/codeAgent/agent/akanCodeModel.ts +5 -39
  49. package/codeAgent/agent/akanCodePaths.ts +4 -24
  50. package/codeAgent/agent/akanContextFiles.test.ts +0 -2
  51. package/codeAgent/agent/akanSystemPrompt.ts +1 -9
  52. package/codeAgent/agent/codeAgent.test.ts +178 -0
  53. package/codeAgent/agent/codeAgentContract.test.ts +5 -28
  54. package/codeAgent/agent/codeMailbox.test.ts +0 -4
  55. package/codeAgent/agent/codeSessionFork.test.ts +0 -2
  56. package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
  57. package/codeAgent/agent/consoleToStderr.ts +2 -12
  58. package/codeAgent/agent/jsonLines.ts +29 -0
  59. package/codeAgent/agent/stringArg.ts +5 -0
  60. package/codeAgent/feedback/DevLogFeedback.ts +3 -18
  61. package/codeAgent/feedback/PreviewChrome.ts +5 -29
  62. package/codeAgent/feedback/PreviewFeedback.ts +2 -9
  63. package/codeAgent/feedback/PreviewView.ts +3 -15
  64. package/codeAgent/feedback/TurnFeedback.ts +2 -17
  65. package/codeAgent/feedback/VerifyFeedback.ts +1 -7
  66. package/codeAgent/feedback/previewChrome.test.ts +1 -1
  67. package/codeAgent/index.ts +2 -10
  68. package/codeAgent/skills/akan-code/SKILL.md +12 -4
  69. package/codeAgent/tools/AkanCli.ts +4 -16
  70. package/codeAgent/tools/AkanCodePlugins.ts +21 -43
  71. package/codeAgent/tools/AkanEditScope.ts +2 -12
  72. package/codeAgent/tools/AkanEnvKeys.ts +3 -11
  73. package/codeAgent/tools/AkanToolPack.ts +6 -28
  74. package/codeAgent/tools/AkanVerifier.ts +1 -8
  75. package/codeAgent/tools/AskToolPack.ts +3 -15
  76. package/codeAgent/tools/MailToolPack.ts +0 -9
  77. package/codeAgent/tools/McpClient.ts +3 -24
  78. package/codeAgent/tools/McpOAuth.ts +6 -41
  79. package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
  80. package/codeAgent/tools/McpServerConfig.ts +7 -26
  81. package/codeAgent/tools/McpSignIn.ts +46 -49
  82. package/codeAgent/tools/McpTokenStore.ts +6 -17
  83. package/codeAgent/tools/McpToolPack.ts +107 -35
  84. package/codeAgent/tools/SessionToolPack.ts +1 -11
  85. package/codeAgent/tools/WebToolPack.ts +1 -7
  86. package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
  87. package/codeAgent/tools/askToolPack.test.ts +0 -3
  88. package/codeAgent/tools/mcpOAuth.test.ts +315 -27
  89. package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
  90. package/codegenLock.test.ts +4 -13
  91. package/codegenLock.ts +3 -17
  92. package/commandDecorators/argMeta.ts +1 -5
  93. package/commandDecorators/camelToKebabCase.ts +1 -0
  94. package/commandDecorators/command.ts +44 -102
  95. package/commandDecorators/commandBuilder.ts +30 -61
  96. package/commandDecorators/commandDecorators.test.ts +1 -9
  97. package/commandDecorators/dependencyBuilder.ts +10 -1
  98. package/commandDecorators/helpFormatter.ts +66 -144
  99. package/commandDecorators/index.ts +0 -2
  100. package/commandDecorators/targetMeta.ts +9 -7
  101. package/createTunnel.ts +7 -14
  102. package/dependencyScanner.test.ts +4 -16
  103. package/dependencyScanner.ts +27 -78
  104. package/devkitUtils.test.ts +47 -19
  105. package/executors.test.ts +114 -60
  106. package/executors.ts +165 -484
  107. package/fileEditor.ts +9 -37
  108. package/{getDirname.test.ts → fileSys.test.ts} +1 -1
  109. package/fileSys.ts +6 -8
  110. package/formSetterScanner.ts +3 -16
  111. package/frontendBuild/allRoutesBuilder.ts +7 -21
  112. package/frontendBuild/autoImportSync.test.ts +14 -38
  113. package/frontendBuild/autoImportSync.ts +15 -67
  114. package/frontendBuild/buildRouteClient.test.ts +10 -36
  115. package/frontendBuild/bundleDefine.ts +12 -0
  116. package/frontendBuild/clientBuildTypes.ts +5 -36
  117. package/frontendBuild/clientEntriesBundler.ts +9 -40
  118. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  119. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  120. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  121. package/frontendBuild/cssCandidateCache.ts +6 -41
  122. package/frontendBuild/cssCompiler.ts +29 -78
  123. package/frontendBuild/cssImportResolver.ts +5 -18
  124. package/frontendBuild/devChangePlanner.ts +7 -10
  125. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  126. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  127. package/frontendBuild/fontOptimizer.test.ts +5 -13
  128. package/frontendBuild/fontOptimizer.ts +15 -72
  129. package/frontendBuild/fontPruner.test.ts +5 -10
  130. package/frontendBuild/fontPruner.ts +5 -20
  131. package/frontendBuild/frontendBuild.test.ts +27 -52
  132. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  133. package/frontendBuild/hmrWatcher.test.ts +7 -31
  134. package/frontendBuild/hmrWatcher.ts +14 -71
  135. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  136. package/frontendBuild/precompressArtifacts.ts +5 -18
  137. package/frontendBuild/routeClientBuilder.ts +7 -47
  138. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  139. package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
  140. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  141. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  142. package/frontendBuild/styleContract.ts +1 -8
  143. package/frontendBuild/themeValidator.ts +4 -26
  144. package/frontendBuild/watchRootResolver.test.ts +23 -42
  145. package/frontendBuild/watchRootResolver.ts +5 -27
  146. package/getRelatedCnsts.ts +12 -92
  147. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  148. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  149. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  150. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  151. package/incrementalBuilder/builderChannel.test.ts +24 -49
  152. package/incrementalBuilder/builderChannel.ts +6 -37
  153. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  154. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  155. package/incrementalBuilder/devWatchBatch.test.ts +14 -18
  156. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  157. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  158. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  159. package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
  160. package/index.ts +3 -15
  161. package/integration/devResourceProbe.ts +50 -56
  162. package/integration/devStability.integration.test.ts +47 -194
  163. package/integration/devStabilityHarness.test.ts +9 -29
  164. package/integration/devStabilityHarness.ts +65 -330
  165. package/integration/ssrMemoryProbe.ts +41 -167
  166. package/libSource.test.ts +4 -15
  167. package/libSource.ts +19 -31
  168. package/lint/gritRules.test.ts +6 -23
  169. package/linter.test.ts +1 -2
  170. package/linter.ts +22 -115
  171. package/mobile/mobileTarget.ts +14 -13
  172. package/package.json +2 -2
  173. package/packageExports.test.ts +3 -18
  174. package/packageExportsMap.ts +3 -19
  175. package/piDependencyPins.test.ts +3 -14
  176. package/platformTest/IdleWatchedProcess.test.ts +5 -13
  177. package/platformTest/IdleWatchedProcess.ts +1 -5
  178. package/platformTest/LinuxDockerTarget.ts +20 -35
  179. package/platformTest/PlatformTestRun.test.ts +5 -9
  180. package/platformTest/PlatformTestTarget.ts +24 -2
  181. package/platformTest/TestSnapshot.test.ts +6 -14
  182. package/platformTest/TestSnapshot.ts +1 -5
  183. package/platformTest/WindowsSshTarget.ts +6 -22
  184. package/prompter.ts +3 -6
  185. package/qualityScanner.test.ts +6 -24
  186. package/qualityScanner.ts +196 -284
  187. package/recipeScanner.test.ts +7 -12
  188. package/recipeScanner.ts +9 -38
  189. package/repoIdentity.ts +2 -9
  190. package/routeSourceValidator.ts +7 -30
  191. package/scanInfo.ts +82 -214
  192. package/selectModel.ts +1 -2
  193. package/semver.ts +3 -5
  194. package/slicePlanner.test.ts +6 -24
  195. package/slicePlanner.ts +11 -32
  196. package/spinner.test.ts +3 -8
  197. package/spinner.ts +4 -16
  198. package/sshTunnel.ts +1 -6
  199. package/ssrScanner.test.ts +1 -6
  200. package/ssrScanner.ts +103 -113
  201. package/subspace.test.ts +6 -25
  202. package/subspace.ts +31 -109
  203. package/subspaceConfig.ts +3 -11
  204. package/testHelpers.ts +49 -34
  205. package/transforms/asyncDefaultExportDetector.ts +3 -11
  206. package/transforms/barrelAnalyzer.ts +43 -135
  207. package/transforms/barrelImportsPlugin.ts +38 -175
  208. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  209. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  210. package/transforms/moduleSyntax.ts +32 -0
  211. package/transforms/rscUseClientTransform.ts +35 -20
  212. package/transforms/transforms.test.ts +153 -44
  213. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  214. package/transforms/useClientBundlePlugin.ts +2 -22
  215. package/typeChecker.ts +7 -63
  216. package/uploadRelease.ts +10 -25
  217. package/workflow/artifacts.ts +18 -0
  218. package/workflow/executor.test.ts +1 -12
  219. package/workflow/executor.ts +85 -146
  220. package/workflow/index.ts +0 -1
  221. package/workflow/moduleIndex.test.ts +4 -15
  222. package/workflow/moduleIndex.ts +9 -49
  223. package/workflow/plan.ts +18 -25
  224. package/workflow/render.ts +80 -96
  225. package/workflow/source.ts +33 -56
  226. package/workflow/sourceAst.ts +50 -0
  227. package/workspaceLayout.ts +4 -19
  228. package/commandDecorators/commandMeta.ts +0 -7
  229. package/commandDecorators/types.ts +0 -10
  230. package/getDirname.ts +0 -4
  231. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  232. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  233. package/workflow/primitive.ts +0 -59
package/linter.ts CHANGED
@@ -58,16 +58,8 @@ interface LintResponse {
58
58
  warnings: LintMessage[];
59
59
  }
60
60
 
61
- /**
62
- * Extracts the `--reporter=json` report from Biome's output.
63
- *
64
- * Biome mixes configuration and IO diagnostics into the same stream as the report, so the output is not
65
- * always JSON alone. Slicing from the first `{` to the last `}` mis-parses as soon as that leading text
66
- * carries a brace of its own, which is not hypothetical: a config error printed ahead of the report made
67
- * one unchanged command alternate between 2 and 10 diagnostics. So every `{` is tried as a start, its
68
- * match is found by depth while string literals are skipped, and the first candidate that both parses and
69
- * carries a report field wins — a brace in prose parses as nothing, and `{"a":1}` in prose is not a report.
70
- */
61
+ // Biome mixes config and IO diagnostics into the report stream, and that text can carry braces of its own, so every
62
+ // `{` is tried as a start (string literals skipped) and the first candidate that parses and carries a report field wins.
71
63
  export const parseBiomeReport = (output: string): BiomeReport => {
72
64
  for (let start = output.indexOf("{"); start !== -1; start = output.indexOf("{", start + 1)) {
73
65
  const end = objectEndAt(output, start);
@@ -111,6 +103,15 @@ const asBiomeReport = (candidate: string): BiomeReport | null => {
111
103
  /** Biome reads `biome.json` first and `biome.jsonc` second; only the latter may carry comments. */
112
104
  const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
113
105
 
106
+ const emptyResult = (filePath: string): LintResult => ({
107
+ filePath,
108
+ messages: [],
109
+ errorCount: 0,
110
+ warningCount: 0,
111
+ fixableErrorCount: 0,
112
+ fixableWarningCount: 0,
113
+ });
114
+
114
115
  export class Linter {
115
116
  //? Bun links a package bin as `<name>.exe` (plus a `.bunx` stub) on Windows, and a bare `<name>` elsewhere.
116
117
  static readonly biomeBinName = process.platform === "win32" ? "biome.exe" : "biome";
@@ -202,16 +203,7 @@ export class Linter {
202
203
  for (const diagnostic of report.diagnostics ?? []) {
203
204
  if (diagnostic.severity !== "error" && diagnostic.severity !== "warning") continue;
204
205
  const diagnosticFilePath = this.#diagnosticFilePath(diagnostic, filePath);
205
- const result =
206
- resultsByPath.get(diagnosticFilePath) ??
207
- ({
208
- filePath: diagnosticFilePath,
209
- messages: [],
210
- errorCount: 0,
211
- warningCount: 0,
212
- fixableErrorCount: 0,
213
- fixableWarningCount: 0,
214
- } satisfies LintResult);
206
+ const result = resultsByPath.get(diagnosticFilePath) ?? emptyResult(diagnosticFilePath);
215
207
  const message = this.#createLintMessage(diagnostic);
216
208
  result.messages.push(message);
217
209
  if (message.severity === 2) result.errorCount += 1;
@@ -220,15 +212,7 @@ export class Linter {
220
212
  }
221
213
 
222
214
  return [
223
- resultsByPath.get(filePath) ??
224
- ({
225
- filePath,
226
- messages: [],
227
- errorCount: 0,
228
- warningCount: 0,
229
- fixableErrorCount: 0,
230
- fixableWarningCount: 0,
231
- } satisfies LintResult),
215
+ resultsByPath.get(filePath) ?? emptyResult(filePath),
232
216
  ...[...resultsByPath.entries()].filter(([resultPath]) => resultPath !== filePath).map(([, result]) => result),
233
217
  ];
234
218
  }
@@ -272,22 +256,12 @@ export class Linter {
272
256
  return await this.lintFile(filePath);
273
257
  }
274
258
 
275
- /**
276
- * Lint a single file using Biome.
277
- * @param filePath - Path to the file to lint
278
- * @returns Array of Biome results in the legacy lint result shape
279
- */
280
259
  async lintFile(filePath: string): Promise<LintResponse> {
281
260
  const resolvedFilePath = this.#resolveFilePath(filePath);
282
261
  if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
283
262
  return await this.#checkFile(resolvedFilePath);
284
263
  }
285
264
 
286
- /**
287
- * Format lint results for console output
288
- * @param results - Array of Biome results
289
- * @returns Formatted string
290
- */
291
265
  formatLintResults(results: LintResult[]): string {
292
266
  if (results.length === 0) return "No files to lint";
293
267
 
@@ -308,7 +282,7 @@ export class Linter {
308
282
  const sourceContent = readFileSync(result.filePath, "utf8");
309
283
  sourceLines = sourceContent.split("\n");
310
284
  } catch {
311
- // Ignore read errors
285
+ //? An unreadable source only loses its code frame.
312
286
  }
313
287
  }
314
288
 
@@ -321,14 +295,12 @@ export class Linter {
321
295
  output.push(`\n ${icon} ${typeColor(type)}: ${message.message}${ruleInfo}`);
322
296
  output.push(` ${chalk.gray("at")} ${result.filePath}:${chalk.bold(`${message.line}:${message.column}`)}`);
323
297
 
324
- // Show source line with underline
325
298
  if (sourceLines.length > 0 && message.line <= sourceLines.length) {
326
299
  const sourceLine = sourceLines[message.line - 1];
327
300
  const lineNumber = message.line.toString().padStart(5, " ");
328
301
 
329
302
  output.push(`\n${chalk.dim(`${lineNumber} |`)} ${sourceLine}`);
330
303
 
331
- // Create underline
332
304
  const underlinePrefix = " ".repeat(message.column - 1);
333
305
  const underlineLength = message.endColumn ? message.endColumn - message.column : 1;
334
306
  const underline = "^".repeat(Math.max(1, underlineLength));
@@ -348,27 +320,7 @@ export class Linter {
348
320
  return summary.concat(output).join("\n");
349
321
  }
350
322
 
351
- /**
352
- * Get detailed lint information
353
- * @param filePath - Path to the file to lint
354
- * @returns Object containing detailed lint information
355
- */
356
- async getDetailedLintInfo(filePath: string): Promise<{
357
- results: LintResult[];
358
- details: {
359
- line: number;
360
- column: number;
361
- message: string;
362
- ruleId: string | null;
363
- severity: "error" | "warning";
364
- }[];
365
- stats: {
366
- errorCount: number;
367
- warningCount: number;
368
- fixableErrorCount: number;
369
- fixableWarningCount: number;
370
- };
371
- }> {
323
+ async getDetailedLintInfo(filePath: string) {
372
324
  const { results } = await this.lintFile(filePath);
373
325
 
374
326
  const details = results.flatMap((result) =>
@@ -388,22 +340,13 @@ export class Linter {
388
340
  fixableErrorCount: acc.fixableErrorCount + result.fixableErrorCount,
389
341
  fixableWarningCount: acc.fixableWarningCount + result.fixableWarningCount,
390
342
  }),
391
- {
392
- errorCount: 0,
393
- warningCount: 0,
394
- fixableErrorCount: 0,
395
- fixableWarningCount: 0,
396
- },
343
+ { errorCount: 0, warningCount: 0, fixableErrorCount: 0, fixableWarningCount: 0 },
397
344
  );
398
345
 
399
346
  return { results, details, stats };
400
347
  }
401
348
 
402
- /**
403
- * Check if a file has lint errors
404
- * @param filePath - Path to the file to check
405
- * @returns true if there are no errors, false otherwise
406
- */
349
+ /** Also false when the file cannot be linted at all. */
407
350
  async hasNoLintErrors(filePath: string): Promise<boolean> {
408
351
  try {
409
352
  const { results } = await this.lintFile(filePath);
@@ -413,32 +356,17 @@ export class Linter {
413
356
  }
414
357
  }
415
358
 
416
- /**
417
- * Get only error messages (excluding warnings)
418
- * @param filePath - Path to the file to lint
419
- * @returns Array of error messages
420
- */
421
359
  async getErrors(filePath: string): Promise<LintMessage[]> {
422
360
  const { results } = await this.lintFile(filePath);
423
361
  return results.flatMap((result) => result.messages.filter((message) => message.severity === 2));
424
362
  }
425
363
 
426
- /**
427
- * Get only warning messages
428
- * @param filePath - Path to the file to lint
429
- * @returns Array of warning messages
430
- */
431
364
  async getWarnings(filePath: string): Promise<LintMessage[]> {
432
365
  const { results } = await this.lintFile(filePath);
433
366
  return results.flatMap((result) => result.messages.filter((message) => message.severity === 1));
434
367
  }
435
368
 
436
- /**
437
- * Fix lint errors automatically
438
- * @param filePath - Path to the file to fix
439
- * @param dryRun - If true, returns the fixed content without writing to file
440
- * @returns Fixed content and remaining issues
441
- */
369
+ /** `dryRun` returns the fixed content in `output` without writing the file. */
442
370
  async fixFile(filePath: string, dryRun = false): Promise<LintResponse> {
443
371
  const resolvedFilePath = this.#resolveFilePath(filePath);
444
372
  if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
@@ -454,19 +382,8 @@ export class Linter {
454
382
  return { ...lintResult, fixed: stdout !== source, output: stdout };
455
383
  }
456
384
 
457
- /**
458
- * Get Biome configuration for a file
459
- * @param filePath - Path to the file
460
- * @returns Biome configuration object
461
- */
462
- /**
463
- * Applies Biome's safe fixes to many files in one process.
464
- *
465
- * One spawn, not one per file: Biome's startup dominates a small file, so the per-file path turns a
466
- * fifteen-file scaffold into three seconds of process launches. Paths that do not exist are dropped
467
- * rather than thrown on, because the caller is usually reporting what a template just wrote and a
468
- * template is allowed to decline a file.
469
- */
385
+ // One spawn for all files: Biome's startup dominates a small file. Missing paths are dropped (a template may
386
+ // decline a file).
470
387
  async fixFiles(filePaths: string[]): Promise<{ fixed: string[] }> {
471
388
  const resolved = filePaths.map((filePath) => this.#resolveFilePath(filePath)).filter(existsSync);
472
389
  if (resolved.length === 0) return { fixed: [] };
@@ -492,21 +409,11 @@ export class Linter {
492
409
  return JSON.parse(readFileSync(this.configPath, "utf8")) as unknown;
493
410
  }
494
411
 
495
- /**
496
- * Get rules that are causing errors in a file
497
- * @param filePath - Path to the file to check
498
- * @returns Object mapping rule IDs to their error counts
499
- */
500
412
  async getProblematicRules(filePath: string): Promise<Record<string, number>> {
501
413
  const { results } = await this.lintFile(filePath);
502
414
  const ruleCounts: Record<string, number> = {};
503
-
504
- results.forEach((result) => {
505
- result.messages.forEach((message) => {
506
- if (message.ruleId) ruleCounts[message.ruleId] = (ruleCounts[message.ruleId] || 0) + 1;
507
- });
508
- });
509
-
415
+ for (const message of results.flatMap((result) => result.messages))
416
+ if (message.ruleId) ruleCounts[message.ruleId] = (ruleCounts[message.ruleId] || 0) + 1;
510
417
  return ruleCounts;
511
418
  }
512
419
  }
@@ -11,6 +11,8 @@ export interface ResolvedMobileTarget {
11
11
 
12
12
  export const MOBILE_ENVS = ["local", "debug", "develop", "main"] as const satisfies readonly MobileEnv[];
13
13
 
14
+ const trimSlashes = (value: string) => value.replace(/^\/+|\/+$/g, "");
15
+
14
16
  export const getMobileTargets = async (app: App): Promise<ResolvedMobileTarget[]> => {
15
17
  const config = await app.getConfig();
16
18
  return Object.entries(config.mobile.targets).map(([name, target]) => ({ name, config: target }));
@@ -20,26 +22,23 @@ export const getMobileTargetChoices = async (app: App): Promise<string[]> => {
20
22
  const config = await app.getConfig();
21
23
  const targetNames = Object.keys(config.mobile.targets);
22
24
  if (targetNames.length > 0) return targetNames;
23
- const basePaths = [...config.basePaths];
24
- return basePaths;
25
+ return [...config.basePaths];
25
26
  };
26
27
 
27
28
  const resolveMobileTargetByBasePath = (
28
29
  targets: ResolvedMobileTarget[],
29
30
  basePath: string,
30
31
  ): ResolvedMobileTarget | undefined => {
31
- const normalizedBasePath = basePath.replace(/^\/+|\/+$/g, "");
32
- const byBasePath = targets.find((target) => target.config.basePath?.replace(/^\/+|\/+$/g, "") === normalizedBasePath);
32
+ const normalizedBasePath = trimSlashes(basePath);
33
+ const byBasePath = targets.find(
34
+ ({ config }) => config.basePath !== undefined && trimSlashes(config.basePath) === normalizedBasePath,
35
+ );
33
36
  if (byBasePath) return byBasePath;
34
37
  const [template] = targets;
35
38
  if (!template) return undefined;
36
39
  return {
37
40
  name: normalizedBasePath,
38
- config: {
39
- ...template.config,
40
- name: normalizedBasePath,
41
- basePath: normalizedBasePath,
42
- },
41
+ config: { ...template.config, name: normalizedBasePath, basePath: normalizedBasePath },
43
42
  };
44
43
  };
45
44
 
@@ -70,18 +69,20 @@ export const resolveMobileTargets = async (
70
69
  const target = targets.find((candidate) => candidate.name === selection);
71
70
  if (target) return [target];
72
71
  const basePathTarget = resolveMobileTargetByBasePath(targets, selection);
73
- if (basePathTarget && config.basePaths.has(selection.replace(/^\/+|\/+$/g, ""))) return [basePathTarget];
72
+ if (basePathTarget && config.basePaths.has(trimSlashes(selection))) return [basePathTarget];
74
73
  const choices = await getMobileTargetChoices(app);
75
74
  throw new Error(`Mobile target '${selection}' was not found. Available: ${choices.join(", ")}`);
76
75
  };
77
76
 
78
77
  export const resolveMobilePath = (target: AkanMobileTargetConfig, pathname: string) => {
79
- const basePath = target.basePath?.replace(/^\/+|\/+$/g, "");
78
+ const basePath = trimSlashes(target.basePath ?? "");
80
79
  const normalizedPath = `/${pathname.replace(/^\/+/, "")}`;
81
80
  if (!basePath) return normalizedPath;
82
81
  if (normalizedPath === `/${basePath}` || normalizedPath.startsWith(`/${basePath}/`)) return normalizedPath;
83
82
  return `/${basePath}${normalizedPath === "/" ? "" : normalizedPath}`;
84
83
  };
85
84
 
86
- export const targetHtmlFilename = (target: AkanMobileTargetConfig) =>
87
- target.basePath?.replace(/^\/+|\/+$/g, "") ? `${target.basePath.replace(/^\/+|\/+$/g, "")}.html` : "index.html";
85
+ export const targetHtmlFilename = (target: AkanMobileTargetConfig) => {
86
+ const basePath = trimSlashes(target.basePath ?? "");
87
+ return basePath ? `${basePath}.html` : "index.html";
88
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.19",
3
+ "version": "3.0.0-beta.20",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
50
  "@trapezedev/project": "^7.1.4",
51
- "akanjs": "3.0.0-beta.19",
51
+ "akanjs": "3.0.0-beta.20",
52
52
  "chalk": "^5.6.2",
53
53
  "commander": "^14.0.3",
54
54
  "dayjs": "^1.11.20",
@@ -3,30 +3,17 @@ import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { PackageExportsMap } from "@akanjs/devkit/packageExportsMap";
5
5
 
6
- // Guards the published shape of this package, which the monorepo cannot exercise on its own.
7
- //
8
- // Inside the monorepo Bun resolves `@akanjs/devkit/executors` through the root tsconfig `paths`
9
- // (`@akanjs/devkit/*` -> `pkgs/@akanjs/devkit/*`), and that resolver probes extensions and directory
10
- // indexes. A published consumer has no such mapping: it goes through this package's `exports` map,
11
- // whose targets are matched *exactly*. So `"./*": "./*"` type-checked, built, and passed every test
12
- // here while every subpath import failed at runtime for anyone installing the tarball:
13
- //
14
- // error: Cannot find module '@akanjs/devkit/executors' from
15
- // '<consumer>/node_modules/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts'
16
- //
17
- // `PackageRunner.verifyDistPackage` runs the same check against the built dist tree of every
18
- // publishable package at release time; these tests keep this package honest on every run.
6
+ //? The monorepo resolves subpaths through tsconfig `paths`, which probe extensions; a published consumer goes through
7
+ //? the exports map, matched exactly — so `"./*": "./*"` passed every test here and broke every installed subpath.
19
8
 
20
9
  const packageDir = import.meta.dir;
21
10
  const exportsMap = await PackageExportsMap.from(packageDir);
22
11
 
23
- /** Every facet the root barrel re-exports, as the subpath a consumer would import. */
24
12
  const barrelFacets = async (): Promise<string[]> => {
25
13
  const barrel = await Bun.file(path.join(packageDir, "index.ts")).text();
26
14
  return [...barrel.matchAll(/^export (?:type )?\* from "\.\/([^"]+)";$/gm)].map((match) => `./${match[1]}`);
27
15
  };
28
16
 
29
- /** Every `@akanjs/devkit/<subpath>` specifier written anywhere in the two packages that use them. */
30
17
  const importedSubpaths = async (): Promise<string[]> => {
31
18
  const repoRoot = path.resolve(packageDir, "../../..");
32
19
  const glob = new Bun.Glob("pkgs/@akanjs/{cli,devkit}/**/*.{ts,tsx}");
@@ -53,9 +40,7 @@ describe("published exports map", () => {
53
40
  });
54
41
 
55
42
  test("covers both facet shapes and keeps explicit extensions intact", () => {
56
- // A single wildcard cannot serve all three: `./*` -> `./*.ts` reaches bare files, directory
57
- // facets need their own literal entry, and `./*.ts` -> `./*.ts` keeps an already-suffixed
58
- // specifier from becoming `./cloud/cloudApi.ts.ts`.
43
+ // One wildcard cannot serve bare files, directory facets and already-suffixed specifiers (no `.ts.ts`) at once.
59
44
  expect(exportsMap.resolve("./executors")).toBe("./executors.ts");
60
45
  expect(exportsMap.resolve("./frontendBuild")).toBe("./frontendBuild/index.ts");
61
46
  expect(exportsMap.resolve("./cloud/cloudApi.ts")).toBe("./cloud/cloudApi.ts");
@@ -7,17 +7,9 @@ export interface UnreachableSubpath {
7
7
  target: string | null;
8
8
  }
9
9
 
10
- /**
11
- * The subset of Node's `exports` resolution that Bun applies to a published package.
12
- *
13
- * Exports targets are matched **exactly**: no extension is appended and no `index.ts` is probed. That
14
- * is invisible inside this monorepo, where `@akanjs/devkit/*` and `akanjs/*` resolve through the root
15
- * tsconfig `paths` instead — a resolver that *does* probe both. A map of `{"./*": "./*"}` therefore
16
- * type-checks, builds, and passes every test while every subpath import fails for anyone who installs
17
- * the tarball. This class exists so that gap can be asserted against before publishing.
18
- */
10
+ // Bun matches exports targets exactly (no extension, no index.ts), while this monorepo resolves `akanjs/*` through
11
+ // tsconfig paths, which probe both — so a broken map passes every in-repo test and fails for every installer.
19
12
  export class PackageExportsMap {
20
- /** Reads `<packageDir>/package.json` and builds the map from its `exports` field. */
21
13
  static async from(packageDir: string) {
22
14
  const manifest = (await Bun.file(path.join(packageDir, "package.json")).json()) as { exports?: unknown };
23
15
  return new PackageExportsMap(packageDir, manifest.exports);
@@ -60,7 +52,6 @@ export class PackageExportsMap {
60
52
  // wins over `./*`, which is what keeps an already-suffixed specifier from gaining a second `.ts`.
61
53
  this.#patterns.sort((a, b) => b.prefix.length - a.prefix.length || b.suffix.length - a.suffix.length);
62
54
  }
63
- /** Returns the target an `exports` lookup yields, or null when the subpath is unexported. */
64
55
  resolve(subpath: string): string | null {
65
56
  const literal = this.#literals.get(subpath);
66
57
  if (literal) return literal;
@@ -71,20 +62,13 @@ export class PackageExportsMap {
71
62
  }
72
63
  return null;
73
64
  }
74
- /**
75
- * Resolves a subpath and reports whether the target it yields is a readable file.
76
- *
77
- * A directory does not count. `{"./*": "./*"}` maps `./commandDecorators` onto the directory of
78
- * that name, which exists but is not a module — an `existsSync` check here reports such a subpath
79
- * as reachable while the import still fails.
80
- */
65
+ // A directory does not count: `{"./*": "./*"}` maps `./commandDecorators` onto a folder, which is not importable.
81
66
  resolveToFile(subpath: string): { target: string | null; exists: boolean } {
82
67
  const target = this.resolve(subpath);
83
68
  if (!target) return { target: null, exists: false };
84
69
  const stat = statSync(path.join(this.#packageDir, target), { throwIfNoEntry: false });
85
70
  return { target, exists: !!stat?.isFile() };
86
71
  }
87
- /** Returns the given subpaths that no consumer could import, in input order. */
88
72
  findUnreachable(subpaths: Iterable<string>): UnreachableSubpath[] {
89
73
  const unreachable: UnreachableSubpath[] = [];
90
74
  for (const subpath of subpaths) {
@@ -2,18 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import type { PackageJson } from "@akanjs/devkit/types";
4
4
 
5
- // Guards the published dependency closure of the code agent, which the monorepo cannot exercise on its own.
6
- //
7
- // `@earendil-works/pi-coding-agent` asks for `^0.80.6` of its three sibling packages and ships an
8
- // `npm-shrinkwrap.json` pinning them to the exact version it was built against. Bun reads neither that file nor
9
- // this workspace's `overrides` when somebody installs the published CLI, so `^0.80.6` resolved to the newest
10
- // 0.80.x — 0.80.10, which dropped `getOAuthApiKey` from `pi-ai/oauth` while the pinned coding agent still
11
- // imports it. A global install died on its first import:
12
- //
13
- // Export named 'getOAuthApiKey' not found in module '.../@earendil-works/pi-ai/dist/oauth.js'.
14
- //
15
- // `PackageRunner` therefore re-declares pi's own pins as dependencies of what we publish; this keeps the two
16
- // equal, so bumping the coding agent without moving the pins fails here rather than at a user's first run.
5
+ //? A published install reads neither pi's npm-shrinkwrap.json nor our overrides, so PackageRunner re-declares pi's
6
+ //? sibling pins (a floating 0.80.x once broke every global install); this keeps them equal to the shrinkwrap.
17
7
 
18
8
  const repoRoot = path.resolve(import.meta.dir, "../../..");
19
9
  const piName = "@earendil-works/pi-coding-agent";
@@ -28,8 +18,7 @@ const readJson = async <T>(file: string) => (await Bun.file(file).json()) as T;
28
18
 
29
19
  const shrinkwrappedPins = async () => {
30
20
  const { packages } = await readJson<Shrinkwrap>(`${piDir}/npm-shrinkwrap.json`);
31
- // Top-level entries only: a key with a second `node_modules/` in it is a *nested placement* of some other
32
- // package's dependency, which a consumer's resolver reaches on its own and we have no business pinning.
21
+ // Top-level entries only: a nested `node_modules/` key is another package's placement, not ours to pin.
33
22
  const entries = Object.entries(packages)
34
23
  .filter(([key]) => /^node_modules\/@earendil-works\/[^/]+$/.test(key))
35
24
  .map(([key, entry]) => [key.replace(/^node_modules\//, ""), entry.version ?? ""] as const);
@@ -1,19 +1,11 @@
1
- import { afterAll, describe, expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFile } from "node:fs/promises";
4
3
  import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
5
5
  import { IdleWatchedProcess } from "./IdleWatchedProcess";
6
6
 
7
- const roots: string[] = [];
8
- const makeLogPath = async () => {
9
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-idle-watch-"));
10
- roots.push(root);
11
- return path.join(root, "nested", "run.log");
12
- };
13
-
14
- afterAll(async () => {
15
- await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
16
- });
7
+ const makeRoot = tempDirs("akan-idle-watch-");
8
+ const makeLogPath = async () => path.join(await makeRoot(), "nested", "run.log");
17
9
 
18
10
  describe("IdleWatchedProcess", () => {
19
11
  test("tees the output of a command that finishes into the log and reports its exit code", async () => {
@@ -19,11 +19,7 @@ interface IdleWatchedOptions {
19
19
  onOutput?: (text: string) => void;
20
20
  }
21
21
 
22
- /**
23
- * Runs a command with its output teed into a log, and gives up on it once the output goes quiet.
24
- * Silence is the only reliable hang signal: a runner stuck in a synchronous loop (Bun 1.4.2's Windows
25
- * websocket crash spun at 100% CPU) never lets bun's own per-test timeout fire.
26
- */
22
+ // Silence is the only reliable hang signal: a runner stuck in a synchronous loop never lets bun's test timeout fire.
27
23
  export class IdleWatchedProcess {
28
24
  readonly #command: string[];
29
25
  readonly #options: IdleWatchedOptions;
@@ -18,8 +18,7 @@ export class LinuxDockerTarget extends PlatformTestTarget {
18
18
  this.policy = config.policy ?? "gate";
19
19
  }
20
20
 
21
- //* No compiler in the image, on purpose: with a toolchain present at install time ssh2 builds its native
22
- //* addon, and Bun then panics loading it (`uv_version_string`, oven-sh/bun#18546).
21
+ //* No compiler in the image: with one, ssh2 builds its native addon and Bun panics loading it (oven-sh/bun#18546).
23
22
  static readonly dockerfile = [
24
23
  `FROM oven/bun:${Bun.version}-slim`,
25
24
  "RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
@@ -30,12 +29,13 @@ export class LinuxDockerTarget extends PlatformTestTarget {
30
29
  }
31
30
 
32
31
  protected async prepare(context: PlatformRunContext) {
33
- if ((await LinuxDockerTarget.#docker(["info"])).exitCode !== 0)
32
+ if ((await LinuxDockerTarget.spawnText(["docker", "info"])).exitCode !== 0)
34
33
  throw new Error("Docker is not running — start Docker Desktop, or leave linux out of --platforms");
35
34
  await this.#ensureImage();
36
35
  const container = `akan-test-${context.runId}`;
37
36
  const cpus = this.#config.cpus ?? Math.max(2, Math.floor(os.availableParallelism() / 2));
38
- const started = await LinuxDockerTarget.#docker([
37
+ const started = await LinuxDockerTarget.spawnText([
38
+ "docker",
39
39
  "run",
40
40
  "-d",
41
41
  "--rm",
@@ -70,51 +70,36 @@ export class LinuxDockerTarget extends PlatformTestTarget {
70
70
  const container = this.#container;
71
71
  if (!container) throw new Error("the linux container is not running");
72
72
  return await new IdleWatchedProcess(
73
- ["docker", "exec", ...LinuxDockerTarget.#gitIdentityEnv(), container, "sh", "-c", command],
73
+ [
74
+ "docker",
75
+ "exec",
76
+ ...Object.entries(PlatformTestTarget.gitIdentityEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
77
+ container,
78
+ "sh",
79
+ "-c",
80
+ command,
81
+ ],
74
82
  {
75
83
  logPath,
76
84
  idleMs,
77
85
  onIdle: async () => {
78
- await LinuxDockerTarget.#docker(["exec", container, "pkill", "-9", "bun"]);
86
+ await LinuxDockerTarget.spawnText(["docker", "exec", container, "pkill", "-9", "bun"]);
79
87
  },
80
88
  },
81
89
  ).run();
82
90
  }
83
91
 
84
92
  protected async cleanup() {
85
- if (this.#container) await LinuxDockerTarget.#docker(["rm", "-f", this.#container]);
93
+ if (this.#container) await LinuxDockerTarget.spawnText(["docker", "rm", "-f", this.#container]);
86
94
  this.#container = null;
87
95
  }
88
96
 
89
97
  async #ensureImage() {
90
- if ((await LinuxDockerTarget.#docker(["image", "inspect", this.image])).exitCode === 0) return;
91
- const built = await LinuxDockerTarget.#docker(["build", "-t", this.image, "-"], LinuxDockerTarget.dockerfile);
98
+ if ((await LinuxDockerTarget.spawnText(["docker", "image", "inspect", this.image])).exitCode === 0) return;
99
+ const built = await LinuxDockerTarget.spawnText(
100
+ ["docker", "build", "-t", this.image, "-"],
101
+ LinuxDockerTarget.dockerfile,
102
+ );
92
103
  if (built.exitCode !== 0) throw new Error(`building ${this.image} failed: ${built.stderr.trim().slice(-400)}`);
93
104
  }
94
-
95
- static #gitIdentityEnv() {
96
- return Object.entries({
97
- GIT_AUTHOR_NAME: "akan-test",
98
- GIT_AUTHOR_EMAIL: "akan-test@localhost",
99
- GIT_COMMITTER_NAME: "akan-test",
100
- GIT_COMMITTER_EMAIL: "akan-test@localhost",
101
- GIT_CONFIG_COUNT: "1",
102
- GIT_CONFIG_KEY_0: "init.defaultBranch",
103
- GIT_CONFIG_VALUE_0: "main",
104
- }).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
105
- }
106
-
107
- static async #docker(args: string[], stdin?: string) {
108
- const proc = Bun.spawn(["docker", ...args], {
109
- stdin: stdin === undefined ? "ignore" : new Response(stdin),
110
- stdout: "pipe",
111
- stderr: "pipe",
112
- });
113
- const [stdout, stderr, exitCode] = await Promise.all([
114
- new Response(proc.stdout).text(),
115
- new Response(proc.stderr).text(),
116
- proc.exited,
117
- ]);
118
- return { stdout, stderr, exitCode };
119
- }
120
105
  }
@@ -1,15 +1,12 @@
1
- import { afterAll, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
5
5
  import { BunTestReport } from "./BunTestReport";
6
6
  import { PlatformTestRun } from "./PlatformTestRun";
7
7
  import type { PackageTestResult, PlatformTestResult } from "./PlatformTestTarget";
8
8
 
9
- const roots: string[] = [];
10
- afterAll(async () => {
11
- await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
12
- });
9
+ const makeRoot = tempDirs("akan-platform-env-");
13
10
 
14
11
  const pkg = (status: PackageTestResult["status"]): PackageTestResult => ({
15
12
  pkg: "akanjs",
@@ -42,8 +39,7 @@ describe("PlatformTestRun", () => {
42
39
  });
43
40
 
44
41
  test("hands a test host only the env keys the CLI needs, never the secrets beside them", async () => {
45
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-platform-env-"));
46
- roots.push(root);
42
+ const root = await makeRoot();
47
43
  await writeFile(
48
44
  path.join(root, ".env"),
49
45
  ["USE_AKANJS_PKGS=true", "OPENAI_API_KEY=sk-secret", 'AKAN_PUBLIC_SERVE_DOMAIN="akanjs.com"', "JEV_KEY=x"].join(