@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.100

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 (194) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +34 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +47 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +70 -42
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +37 -2
  22. package/applicationBuildRunner.ts +53 -24
  23. package/artifact/implicitRootLayout.test.ts +67 -0
  24. package/artifact/implicitRootLayout.ts +27 -7
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +340 -0
  27. package/biomeBase.ts +9 -0
  28. package/capacitorApp.test.ts +8 -0
  29. package/capacitorApp.ts +122 -18
  30. package/cloud/globalConfig.ts +9 -2
  31. package/codegenLock.test.ts +137 -0
  32. package/codegenLock.ts +136 -0
  33. package/commandDecorators/argMeta.ts +9 -1
  34. package/commandDecorators/command.ts +94 -7
  35. package/commandDecorators/commandBuilder.ts +20 -5
  36. package/commandDecorators/commandDecorators.test.ts +115 -3
  37. package/commandDecorators/helpFormatter.ts +4 -0
  38. package/dependencyScanner.test.ts +99 -0
  39. package/dependencyScanner.ts +27 -24
  40. package/devkitUtils.test.ts +0 -104
  41. package/executors.test.ts +137 -5
  42. package/executors.ts +116 -26
  43. package/fileEditor.ts +19 -19
  44. package/fileSys.ts +19 -1
  45. package/formSetterScanner.test.ts +80 -0
  46. package/formSetterScanner.ts +92 -0
  47. package/frontendBuild/allRoutesBuilder.ts +27 -8
  48. package/frontendBuild/autoImportSync.test.ts +58 -0
  49. package/frontendBuild/autoImportSync.ts +7 -2
  50. package/frontendBuild/buildRouteClient.test.ts +47 -24
  51. package/frontendBuild/clientBuildTypes.ts +4 -0
  52. package/frontendBuild/clientEntriesBundler.ts +4 -1
  53. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  54. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  55. package/frontendBuild/cssCandidateCache.test.ts +38 -0
  56. package/frontendBuild/cssCandidateCache.ts +15 -3
  57. package/frontendBuild/cssCompiler.ts +123 -12
  58. package/frontendBuild/cssImportResolver.ts +8 -7
  59. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  60. package/frontendBuild/fontOptimizer.ts +37 -18
  61. package/frontendBuild/fontPruner.test.ts +220 -0
  62. package/frontendBuild/fontPruner.ts +206 -0
  63. package/frontendBuild/frontendBuild.test.ts +178 -10
  64. package/frontendBuild/hmrWatcher.ts +1 -1
  65. package/frontendBuild/index.ts +1 -1
  66. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  67. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  68. package/frontendBuild/precompressArtifacts.ts +35 -7
  69. package/frontendBuild/routeClientBuilder.ts +26 -7
  70. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  71. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  72. package/frontendBuild/styleContract.ts +14 -20
  73. package/frontendBuild/themeValidator.ts +22 -17
  74. package/frontendBuild/vendorSpecifiers.ts +1 -0
  75. package/frontendBuild/watchRootResolver.test.ts +129 -0
  76. package/frontendBuild/watchRootResolver.ts +66 -1
  77. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  78. package/incrementalBuilder/devWatchBatch.ts +6 -2
  79. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  80. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  81. package/index.ts +0 -7
  82. package/integration/devStabilityHarness.ts +2 -10
  83. package/libSource.test.ts +112 -0
  84. package/libSource.ts +126 -0
  85. package/lint/__fixtures__/README.md +40 -0
  86. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  87. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  88. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  89. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  90. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  91. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  92. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  93. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  94. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  95. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  96. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  97. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  98. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  99. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  100. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  101. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  102. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  103. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  104. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  105. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  106. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  107. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  108. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  109. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  110. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  111. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  112. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  113. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  114. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  115. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  116. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  117. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  118. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  119. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  120. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  121. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  122. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  123. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  124. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  125. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  126. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  127. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  128. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  129. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  130. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  131. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  132. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  133. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  134. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  135. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  136. package/lint/gritRules.test.ts +178 -0
  137. package/lint/no-arbitrary-color.grit +1 -1
  138. package/lint/no-async-component-in-ui.grit +35 -0
  139. package/lint/no-bang-comment-in-client.grit +33 -0
  140. package/lint/no-daisyui-legacy-class.grit +26 -9
  141. package/lint/no-deprecated-log-level.grit +17 -0
  142. package/lint/no-import-client-in-server.grit +48 -0
  143. package/lint/no-import-server-in-client.grit +45 -0
  144. package/lint/no-init-fetch-in-client.grit +47 -0
  145. package/lint/no-inline-color.grit +10 -8
  146. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  147. package/lint/no-model-type-in-util-zone.grit +58 -0
  148. package/lint/no-raw-palette-class.grit +3 -3
  149. package/lint/no-return-in-store-action.grit +35 -0
  150. package/lint/no-unpublished-form-setter.grit +41 -0
  151. package/lint/non-scalar-props-restricted.grit +16 -7
  152. package/linter.test.ts +80 -0
  153. package/linter.ts +99 -25
  154. package/package.json +6 -8
  155. package/prompter.ts +9 -4
  156. package/qualityScanner.test.ts +130 -0
  157. package/qualityScanner.ts +91 -62
  158. package/recipeScanner.ts +4 -1
  159. package/repoIdentity.ts +42 -0
  160. package/routeSourceValidator.test.ts +41 -0
  161. package/routeSourceValidator.ts +2 -44
  162. package/scanInfo.ts +32 -67
  163. package/semver.test.ts +26 -0
  164. package/semver.ts +31 -0
  165. package/slicePlanner.test.ts +182 -0
  166. package/slicePlanner.ts +235 -0
  167. package/ssrScanner.test.ts +301 -0
  168. package/subspace.test.ts +418 -0
  169. package/subspace.ts +723 -0
  170. package/subspaceConfig.ts +76 -0
  171. package/transforms/asyncDefaultExportDetector.ts +103 -0
  172. package/transforms/barrelImportsPlugin.ts +1 -1
  173. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  174. package/transforms/transforms.test.ts +5 -5
  175. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  176. package/tsconfig.json +6 -2
  177. package/typeChecker.ts +1 -1
  178. package/types.ts +1 -0
  179. package/uploadRelease.ts +2 -2
  180. package/workflow/executor.test.ts +146 -0
  181. package/workspaceLayout.test.ts +78 -0
  182. package/workspaceLayout.ts +106 -0
  183. package/builder.ts +0 -164
  184. package/extractDeps.ts +0 -86
  185. package/frontendBuild/styleGuard.test.ts +0 -165
  186. package/frontendBuild/styleGuard.ts +0 -322
  187. package/getCredentials.ts +0 -19
  188. package/getModelFileData.ts +0 -62
  189. package/src/capacitorApp.ts +0 -282
  190. package/streamAi.ts +0 -45
  191. package/ui/MultiScrollList.tsx +0 -242
  192. package/ui/ScrollList.tsx +0 -107
  193. package/ui/index.ts +0 -2
  194. package/useStdoutDimensions.ts +0 -20
package/executors.ts CHANGED
@@ -23,6 +23,7 @@ import { pathToFileURL } from "node:url";
23
23
  import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
24
24
  import {
25
25
  capitalize,
26
+ getPageSourceFileViolation,
26
27
  isRouteSourceFile,
27
28
  Logger,
28
29
  parseRouteModuleKey,
@@ -39,9 +40,12 @@ import {
39
40
  upsertAgentBlock,
40
41
  } from "./agentsIndex";
41
42
  import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
43
+ import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
44
+ import { CodegenLock } from "./codegenLock";
42
45
  import { FileSys } from "./fileSys";
43
46
  import { getDirname } from "./getDirname";
44
47
  import { Linter } from "./linter";
48
+ import { resolveRepoName } from "./repoIdentity";
45
49
  import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
46
50
  import { Spinner } from "./spinner";
47
51
  // Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
@@ -232,7 +236,7 @@ export class Executor {
232
236
  );
233
237
  });
234
238
  proc.on("exit", (code, signal) => {
235
- if (!!code || signal)
239
+ if (code || signal)
236
240
  reject(
237
241
  new CommandExecutionError({
238
242
  command,
@@ -341,7 +345,7 @@ export class Executor {
341
345
  );
342
346
  });
343
347
  proc.on("exit", (code, signal) => {
344
- if (!!code || signal)
348
+ if (code || signal)
345
349
  reject(
346
350
  new CommandExecutionError({
347
351
  command: modulePath,
@@ -429,7 +433,9 @@ export class Executor {
429
433
  const writePath = this.getPath(filePath);
430
434
  const dir = path.dirname(writePath);
431
435
  if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
432
- let contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
436
+ //? Biome formats every tracked .json and always ends a file with a newline, so a JSON write without one
437
+ //? loses a byte to `akan lint` and takes it back on the next `akan sync` — a permanent one-line git diff.
438
+ let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
433
439
 
434
440
  if (await FileSys.fileExists(writePath)) {
435
441
  const currentContent = await FileSys.readText(writePath);
@@ -685,7 +691,30 @@ export class Executor {
685
691
  Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
686
692
  ),
687
693
  };
688
- return this._applyTemplate({ ...options, dict });
694
+ const fileContents = await this._applyTemplate({ ...options, dict });
695
+ await this.#formatAppliedTemplate(fileContents);
696
+ return fileContents;
697
+ }
698
+
699
+ /**
700
+ * A template emits identifiers it cannot sort. `import { fetch, Task, usePage }` is correctly ordered for
701
+ * a model named Task and wrong for one named Zoo, and `organizeImports` fails `biome check` — so a
702
+ * scaffold that is not formatted on the way out is red for most model names, whatever the template says.
703
+ *
704
+ * Best-effort: `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary
705
+ * and often no config above the target yet. An unformatted file is a lint fix; a failed scaffold is not.
706
+ */
707
+ async #formatAppliedTemplate(fileContents: FileContent[]) {
708
+ const filePaths = fileContents
709
+ .map((fileContent) => fileContent.filePath)
710
+ .filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
711
+ if (filePaths.length === 0) return;
712
+ try {
713
+ const { fixed } = await this.getLinter().fixFiles(filePaths);
714
+ if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
715
+ } catch (err) {
716
+ this.logger.verbose(`Skipped formatting scaffolded files: ${err instanceof Error ? err.message : String(err)}`);
717
+ }
689
718
  }
690
719
  // Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
691
720
  // not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
@@ -788,7 +817,7 @@ export class WorkspaceExecutor extends Executor {
788
817
  static #execs = new Map<string, WorkspaceExecutor>();
789
818
  static fromRoot({
790
819
  workspaceRoot = process.cwd(),
791
- repoName = path.basename(process.cwd()),
820
+ repoName = resolveRepoName(workspaceRoot),
792
821
  }: {
793
822
  workspaceRoot?: string;
794
823
  repoName?: string;
@@ -908,9 +937,30 @@ export class WorkspaceExecutor extends Executor {
908
937
  return await getDirs(basePath);
909
938
  }
910
939
  async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
911
- if (init) await this.exec(`git init --quiet`);
912
- if (add) await this.exec(`git add .`);
913
- await this.exec(`git commit --quiet -m "${message}"`);
940
+ if (init) await this.spawn("git", ["init", "--quiet"]);
941
+ if (add) await this.spawn("git", ["add", "."]);
942
+ // Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
943
+ await this.spawn("git", ["commit", "--quiet", "-m", message]);
944
+ }
945
+ /** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
946
+ async hasChanges() {
947
+ return !!(await this.spawn("git", ["status", "--porcelain"])).trim();
948
+ }
949
+ /**
950
+ * Workspace-relative paths git knows about, sorted. `untracked` adds files that exist but are not
951
+ * committed yet, still honoring `.gitignore` — which is what a freshly copied library looks like.
952
+ *
953
+ * Reading the file set from git is what keeps generated barrels, the `page/**` and `public/libs`
954
+ * symlinks, env values and the lockfile out of it without any caller restating that list.
955
+ */
956
+ async listGitFiles(paths: string[], { untracked = false }: { untracked?: boolean } = {}) {
957
+ if (!paths.length) return [];
958
+ const mode = untracked ? ["--cached", "--others", "--exclude-standard"] : ["--cached"];
959
+ const stdout = await this.spawn("git", ["ls-files", "-z", ...mode, "--", ...paths]);
960
+ return stdout
961
+ .split("\0")
962
+ .filter((file) => !!file)
963
+ .sort();
914
964
  }
915
965
  async #getDirHasFile(basePath: string, targetFilename: string) {
916
966
  const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
@@ -1005,6 +1055,16 @@ export class SysExecutor extends Executor {
1005
1055
  const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
1006
1056
  return await this.workspace.getDirInModule(path, this.name);
1007
1057
  }
1058
+ async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
1059
+ if (!(await this.exists("page"))) return [];
1060
+ const violations: { relativePath: string; reason: string }[] = [];
1061
+ for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
1062
+ const relativePath = entry.split(path.sep).join("/");
1063
+ const reason = getPageSourceFileViolation(relativePath);
1064
+ if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
1065
+ }
1066
+ return violations;
1067
+ }
1008
1068
 
1009
1069
  #scanInfo: AppInfo | LibInfo | null = null;
1010
1070
  hasScanInfo() {
@@ -1077,18 +1137,22 @@ export class SysExecutor extends Executor {
1077
1137
  : await LibInfo.fromExecutor(this as unknown as LibExecutor, {
1078
1138
  refresh,
1079
1139
  });
1080
- if (write) {
1081
- await Promise.all(this.#getScanTemplateTasks(scanInfo));
1082
- await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
1083
- if (this.type === "lib") this.#updateDependencies(scanInfo);
1140
+ //* `writeLib` regenerates every dependency lib's barrels, and each mounting app's `akan start`
1141
+ //* regenerates the same ones — so this region races the other dev servers in the workspace and the
1142
+ //* builders that watch what it writes.
1143
+ if (write)
1144
+ await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
1145
+ await Promise.all(this.#getScanTemplateTasks(scanInfo));
1146
+ await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
1147
+ if (this.type === "lib") this.#updateDependencies(scanInfo);
1084
1148
 
1085
- if (writeLib) {
1086
- const libInfos = [...scanInfo.getLibInfos().values()];
1087
- await this.#updateDependencies(scanInfo);
1088
- await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
1089
- }
1090
- await this.syncAgentsIndex(scanInfo);
1091
- }
1149
+ if (writeLib) {
1150
+ const libInfos = [...scanInfo.getLibInfos().values()];
1151
+ await this.#updateDependencies(scanInfo);
1152
+ await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
1153
+ }
1154
+ await this.syncAgentsIndex(scanInfo);
1155
+ });
1092
1156
  this.#scanInfo = scanInfo;
1093
1157
  return scanInfo;
1094
1158
  }
@@ -1346,6 +1410,8 @@ export class AppExecutor extends SysExecutor {
1346
1410
  const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
1347
1411
  const routeEnv = {
1348
1412
  AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
1413
+ AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
1414
+ AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
1349
1415
  AKAN_DATABASE_MODE: databaseMode,
1350
1416
  };
1351
1417
  Object.assign(process.env, routeEnv);
@@ -1354,15 +1420,30 @@ export class AppExecutor extends SysExecutor {
1354
1420
  //* generated against and typechecked. Drop the cache so the build phases re-read it without them.
1355
1421
  this.#excludeDevOnlyPages = true;
1356
1422
  this.#pageKeys = null;
1357
- if (await this.exists(this.dist.cwdPath)) await this.dist.exec(`rm -rf ${this.dist.cwdPath}`);
1423
+ await this.dist.removeDir(this.dist.cwdPath);
1358
1424
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1359
1425
  //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
1360
1426
  //* release tarball root, neither of which follows a link out of itself, so materialize them here.
1427
+ //* `public/` has exactly one reader — the web router's catch-all — so an api-only build leaves it out.
1361
1428
  await Promise.all([
1362
1429
  this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1363
- this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1430
+ ...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
1364
1431
  ]);
1365
- } else await this.removeDir(".akan");
1432
+ } else {
1433
+ await this.removeDir(".akan");
1434
+ //? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
1435
+ //? builder is also the file watcher, so switching it off would take server-code HMR with it.
1436
+ if (!akanConfig.web.ssr || !akanConfig.web.csr)
1437
+ this.logger.verbose(
1438
+ `akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
1439
+ );
1440
+ }
1441
+ //? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
1442
+ //? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
1443
+ //? and a dev server that believes it is production serves from the production route cache, whose every
1444
+ //? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
1445
+ //? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
1446
+ if (type === "start") process.env.NODE_ENV = "development";
1366
1447
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1367
1448
  const env = this.getCommandEnv({
1368
1449
  AKAN_COMMAND_TYPE: type,
@@ -1374,7 +1455,15 @@ export class AppExecutor extends SysExecutor {
1374
1455
  // which filters to AKAN_PUBLIC_*) sees AKAN_PUBLIC_APP_NAME — otherwise SSR throws
1375
1456
  // "environment variable AKAN_PUBLIC_APP_NAME is required". Only AKAN_PUBLIC_* is baked into bundles, so
1376
1457
  // this does not leak non-public env.
1377
- if (type === "build") Object.assign(process.env, env);
1458
+ // The port keys are this machine's dev allocation, and `define` turns an `AKAN_PUBLIC_*` into a literal the
1459
+ // artifact can never be run with a different value for — a baked port would outrank the `PORT` the container
1460
+ // is started with and send every SSR self-call to a port nothing bound.
1461
+ if (type === "build") {
1462
+ const buildEnv = { ...env };
1463
+ delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1464
+ delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1465
+ Object.assign(process.env, buildEnv);
1466
+ }
1378
1467
  return { env };
1379
1468
  }
1380
1469
  #publicEnv: Record<string, string> | null = null;
@@ -1451,7 +1540,8 @@ export class AppExecutor extends SysExecutor {
1451
1540
  );
1452
1541
  }
1453
1542
  if (!owner) owners.set(routeId, { absPath, fromLib });
1454
- const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1543
+ const layoutSegments = getRootBoundarySegments(key);
1544
+ const isRootLayout = layoutSegments !== null && isRootBoundarySegments(layoutSegments, akanConfig.basePaths);
1455
1545
  const routeSource = await Bun.file(absPath).text();
1456
1546
  const validator = await AppExecutor.#getRouteSourceValidator();
1457
1547
  if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
@@ -1700,7 +1790,7 @@ export class LibExecutor extends SysExecutor {
1700
1790
  #akanConfig: AkanLibConfig | null = null;
1701
1791
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1702
1792
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1703
- this.#akanConfig = await AkanLibConfig.from(this);
1793
+ this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
1704
1794
  return this.#akanConfig;
1705
1795
  }
1706
1796
  }
@@ -1806,7 +1896,7 @@ export class PkgExecutor extends Executor {
1806
1896
  default: "./index.ts",
1807
1897
  },
1808
1898
  },
1809
- engines: { bun: ">=1.3.13" },
1899
+ engines: { bun: ">=1.4.0" },
1810
1900
  ...dependencyMaps,
1811
1901
  };
1812
1902
  await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
package/fileEditor.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  export class FileEditor {
2
- private filePath: string;
3
- private content: string;
2
+ #filePath: string;
3
+ #content: string;
4
4
 
5
5
  private constructor(filePath: string, content: string) {
6
- this.filePath = filePath;
7
- this.content = content;
6
+ this.#filePath = filePath;
7
+ this.#content = content;
8
8
  }
9
9
 
10
10
  static async create(filePath: string): Promise<FileEditor> {
11
11
  try {
12
12
  const content = await Bun.file(filePath).text();
13
13
  return new FileEditor(filePath, content);
14
- } catch (error) {
14
+ } catch (_error) {
15
15
  throw new Error(`Failed to read file: ${filePath}`);
16
16
  }
17
17
  }
18
18
 
19
19
  find(pattern: string | RegExp): number {
20
- const lines = this.content.split("\n");
20
+ const lines = this.#content.split("\n");
21
21
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
22
22
 
23
23
  for (let i = 0; i < lines.length; i++) {
@@ -30,7 +30,7 @@ export class FileEditor {
30
30
  }
31
31
 
32
32
  findAll(pattern: string | RegExp): number[] {
33
- const lines = this.content.split("\n");
33
+ const lines = this.#content.split("\n");
34
34
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
35
35
  const matches: number[] = [];
36
36
 
@@ -50,9 +50,9 @@ export class FileEditor {
50
50
  throw new Error(`Pattern not found: ${pattern}`);
51
51
  }
52
52
 
53
- const lines = this.content.split("\n");
53
+ const lines = this.#content.split("\n");
54
54
  lines.splice(lineIndex + 1, 0, data);
55
- this.content = lines.join("\n");
55
+ this.#content = lines.join("\n");
56
56
 
57
57
  return this;
58
58
  }
@@ -64,43 +64,43 @@ export class FileEditor {
64
64
  throw new Error(`Pattern not found: ${pattern}`);
65
65
  }
66
66
 
67
- const lines = this.content.split("\n");
67
+ const lines = this.#content.split("\n");
68
68
  lines.splice(lineIndex, 0, data);
69
- this.content = lines.join("\n");
69
+ this.#content = lines.join("\n");
70
70
 
71
71
  return this;
72
72
  }
73
73
 
74
74
  replace(pattern: string | RegExp, replacement: string): this {
75
75
  const regex = typeof pattern === "string" ? new RegExp(pattern, "g") : pattern;
76
- this.content = this.content.replace(regex, replacement);
76
+ this.#content = this.#content.replace(regex, replacement);
77
77
  return this;
78
78
  }
79
79
 
80
80
  append(data: string): this {
81
- this.content += `\n${data}`;
81
+ this.#content += `\n${data}`;
82
82
  return this;
83
83
  }
84
84
 
85
85
  prepend(data: string): this {
86
- this.content = `${data}\n${this.content}`;
86
+ this.#content = `${data}\n${this.#content}`;
87
87
  return this;
88
88
  }
89
89
 
90
90
  async save(): Promise<void> {
91
91
  try {
92
- await Bun.write(this.filePath, this.content);
93
- } catch (error) {
94
- throw new Error(`Failed to save file: ${this.filePath}`);
92
+ await Bun.write(this.#filePath, this.#content);
93
+ } catch (_error) {
94
+ throw new Error(`Failed to save file: ${this.#filePath}`);
95
95
  }
96
96
  }
97
97
 
98
98
  getContent(): string {
99
- return this.content;
99
+ return this.#content;
100
100
  }
101
101
 
102
102
  setContent(content: string): this {
103
- this.content = content;
103
+ this.#content = content;
104
104
  return this;
105
105
  }
106
106
  }
package/fileSys.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { lstat, stat } from "node:fs/promises";
1
+ import { lstat, rename, rm, stat } from "node:fs/promises";
2
2
  import { Logger } from "akanjs/common";
3
3
 
4
4
  export class FileSys {
@@ -39,6 +39,24 @@ export class FileSys {
39
39
  static async writeText(path: string, content: string) {
40
40
  return await Bun.file(path).write(content);
41
41
  }
42
+ /**
43
+ * Replaces a file in one `rename`, so a concurrent reader sees either the old bytes or the new ones.
44
+ * Generated barrels are written by every dev server watching the same `libs/` tree while those same
45
+ * servers' watchers are reading them, and a half-written barrel reads back as a user edit.
46
+ *
47
+ * The temp file is a sibling because `rename` is only atomic within a filesystem, and it lands in a
48
+ * watched directory — the `.tmp` suffix is what `HmrChangeClassifier` ignores it by.
49
+ */
50
+ static async writeTextAtomic(filePath: string, content: string) {
51
+ const temp = `${filePath}.${process.pid}.${Date.now().toString(36)}.tmp`;
52
+ try {
53
+ await Bun.write(temp, content);
54
+ await rename(temp, filePath);
55
+ } catch (error) {
56
+ await rm(temp, { force: true }).catch(() => undefined);
57
+ throw error;
58
+ }
59
+ }
42
60
  static async writeJson(path: string, content: object) {
43
61
  return await Bun.file(path).write(`${JSON.stringify(content, null, 2)}\n`);
44
62
  }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { FormSetterScanner } from "./formSetterScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
5
+
6
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
7
+ file,
8
+ absolutePath: `/tmp/${file}`,
9
+ content,
10
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
11
+ });
12
+
13
+ const scan = (content: string) =>
14
+ new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
15
+
16
+ describe("FormSetterScanner", () => {
17
+ test("a setter passed by reference publishes, so nothing is reported", () => {
18
+ expect(
19
+ scan(`export const General = () => (
20
+ <Field.Text value={taskForm.title} onChange={st.do.setTitleOnTask} />
21
+ );
22
+ `),
23
+ ).toEqual([]);
24
+ });
25
+
26
+ test("every wrapper shape is counted once per field, whatever the wrapper was for", () => {
27
+ const warnings = scan(`export const General = () => (
28
+ <>
29
+ <Field.Text onChange={st.do.setTitleOnTask} />
30
+ <Field.ToggleSelect onChange={(type) => { st.do.setTypeOnTask(type); }} />
31
+ <Field.Phone onChange={(phone) => st.do.setPhoneOnTask(formatPhone(phone))} />
32
+ <Field.Parent
33
+ onChange={(project) => {
34
+ st.do.setProjectOnTask(project);
35
+ if (project) st.do.addMembersOnTask(project.members ?? []);
36
+ }}
37
+ />
38
+ </>
39
+ );
40
+ `);
41
+
42
+ expect(warnings).toHaveLength(1);
43
+ expect(warnings[0]?.rule).toBe("akan.agent.unpublished-form-setter");
44
+ expect(warnings[0]?.scope).toBe("agent");
45
+ expect(warnings[0]?.message).toContain("setPhoneOnTask, setProjectOnTask, setTypeOnTask");
46
+ expect(warnings[0]?.locations).toHaveLength(3);
47
+ });
48
+
49
+ test("a nested path write is unannotatable by design and is not counted", () => {
50
+ expect(
51
+ scan(`export const Rows = ({ idx }: { idx: number }) => (
52
+ <Field.Text onChange={(title) => st.do.writeOnTask(\`payments.\${idx}.title\`, title)} />
53
+ );
54
+ `),
55
+ ).toEqual([]);
56
+ });
57
+
58
+ test("a zero-parameter handler is a button setting a constant, not a form control", () => {
59
+ expect(
60
+ scan(`export const Actions = () => (
61
+ <Button onClick={() => st.do.setStatusOnTask("done")}>Done</Button>
62
+ );
63
+ `),
64
+ ).toEqual([]);
65
+ });
66
+
67
+ test("a non-setter action behind a wrapper is somebody else's rule", () => {
68
+ expect(
69
+ scan(`export const Filters = () => (
70
+ <Select onChange={(ids) => st.do.setQueryArgsOfTaskInSelf(ids)} />
71
+ );
72
+ `),
73
+ ).toEqual([]);
74
+ });
75
+
76
+ test("only .tsx is scanned", () => {
77
+ const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
78
+ expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
79
+ });
80
+ });
@@ -0,0 +1,92 @@
1
+ import ts from "typescript";
2
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
+
4
+ /**
5
+ * The inventory of model fields a screen writes but does not publish.
6
+ *
7
+ * A control handed `onChange={st.do.setTitleOnTask}` **by reference** names the field it writes, so it emits
8
+ * `data-akan-action` and `useFieldTool` publishes the field to the in-page agent. Any wrapper around that setter is
9
+ * an anonymous closure carrying neither, and the field goes quiet — for the agent, for an E2E selector, and for the
10
+ * accessibility tree.
11
+ *
12
+ * `no-unpublished-form-setter.grit` is the per-line enforcement, and it fires only on a pure forwarding wrapper,
13
+ * because every other shape has a legitimate reading and a lint error would be wrong. This is the other half: the
14
+ * per-file count of fields that ended up unreachable whatever the reason, which is the number worth watching. A
15
+ * warning rather than an error for the same reason — the remedy depends on why the wrapper is there.
16
+ *
17
+ * Only a handler that takes a parameter is counted. A zero-parameter handler (`onClick={() => st.do.setStatusOnUser
18
+ * ("active")}`) is a button setting a constant, not a form control, and its remedy is an `st.tool` beside it.
19
+ */
20
+ export class FormSetterScanner {
21
+ static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
22
+
23
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
24
+ return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
25
+ }
26
+
27
+ #scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
28
+ const wrapped: { setter: string; line: number }[] = [];
29
+ const visit = (node: ts.Node) => {
30
+ if (ts.isJsxAttribute(node)) {
31
+ const setter = FormSetterScanner.#wrappedSetterOf(node);
32
+ if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
33
+ }
34
+ ts.forEachChild(node, visit);
35
+ };
36
+ visit(sourceFile);
37
+ if (!wrapped.length) return [];
38
+ const setters = [...new Set(wrapped.map((entry) => entry.setter))].sort();
39
+ return [
40
+ {
41
+ rule: "akan.agent.unpublished-form-setter",
42
+ scope: "agent",
43
+ severity: "warning",
44
+ message:
45
+ setters.length === 1
46
+ ? `${setters[0]} is reached through a wrapper, so that field publishes no agent tool and carries no data-akan-action.`
47
+ : `${setters.length} field setters are reached through a wrapper, so those fields publish no agent tool and carry no data-akan-action: ${setters.join(", ")}.`,
48
+ file,
49
+ line: wrapped[0]?.line,
50
+ locations: wrapped.map(({ line }) => ({ file, line })),
51
+ },
52
+ ];
53
+ }
54
+
55
+ /** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
56
+ static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
57
+ if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
58
+ const initializer = attribute.initializer;
59
+ if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
60
+ const handler = initializer.expression;
61
+ if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
62
+ if (!handler.parameters.length) return null;
63
+ return FormSetterScanner.#setterCallIn(handler.body);
64
+ }
65
+
66
+ static #setterCallIn(node: ts.Node): string | null {
67
+ let found: string | null = null;
68
+ const visit = (current: ts.Node) => {
69
+ if (found) return;
70
+ if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
71
+ const { expression, name } = current.expression;
72
+ if (
73
+ ts.isPropertyAccessExpression(expression) &&
74
+ ts.isIdentifier(expression.expression) &&
75
+ expression.expression.text === "st" &&
76
+ expression.name.text === "do" &&
77
+ FormSetterScanner.#fieldSetter.test(name.text)
78
+ ) {
79
+ found = name.text;
80
+ return;
81
+ }
82
+ }
83
+ ts.forEachChild(current, visit);
84
+ };
85
+ visit(node);
86
+ return found;
87
+ }
88
+
89
+ static #lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
90
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
91
+ }
92
+ }
@@ -47,11 +47,24 @@ export class AllRoutesBuilder {
47
47
  this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
48
48
  this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
49
49
 
50
+ // Discovery first, bundling second. Chunk splitting only dedupes within one `Bun.build`, so a
51
+ // dependency shared by entries from different routes was emitted once per route that reached it.
52
+ // Discovery is cached and does no bundling, so collecting every entry up front costs almost nothing.
53
+ const allEntries: string[] = [];
54
+ const seen = new Set<string>();
50
55
  for (const entry of seedIndex.entries) {
51
56
  const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
52
- const delta = await this.#buildRoute(entry.routeId, seeds);
53
- this.#mergeRoute(entry.routeId, delta);
57
+ for (const discovered of await this.#discovery.discover(seeds)) {
58
+ if (seen.has(discovered)) continue;
59
+ seen.add(discovered);
60
+ allEntries.push(discovered);
61
+ }
62
+ this.#routeIds.push(entry.routeId);
54
63
  }
64
+ this.#app.verbose(`[build-all] ${allEntries.length} client entries across ${this.#routeIds.length} routes`);
65
+
66
+ const delta = await this.#buildEntries(allEntries);
67
+ this.#mergeDelta(delta);
55
68
  this.#merged.knownEntries = Array.from(this.#knownSet);
56
69
 
57
70
  const manifest: RoutesManifest = {
@@ -76,28 +89,34 @@ export class AllRoutesBuilder {
76
89
  return { manifest, manifestPath, seedIndex };
77
90
  }
78
91
 
79
- async #buildRoute(routeId: string, seeds: string[]): Promise<BuildRouteClientResult> {
92
+ async #buildEntries(entries: string[]): Promise<BuildRouteClientResult> {
80
93
  if (!this.#discovery) throw new Error("[build-all] client entry discovery is not initialized");
94
+ if (entries.length === 0)
95
+ return {
96
+ manifestDelta: {},
97
+ ssrManifestDelta: { moduleLoading: null, moduleMap: {} },
98
+ newEntries: [],
99
+ clientDeps: [],
100
+ };
81
101
  const started = Date.now();
82
102
  const delta = await new RouteClientBuilder({
83
103
  app: this.#app,
84
- routeId,
85
- seeds,
104
+ seeds: [],
105
+ entries,
86
106
  artifact: this.#artifact,
87
107
  knownEntries: this.#knownSet,
88
108
  discovery: this.#discovery,
89
109
  command: this.#command,
90
110
  }).build();
91
- this.#app.verbose(`[build-all] ${routeId} +${delta.newEntries.length} entries (${Date.now() - started}ms)`);
111
+ this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
92
112
  return delta;
93
113
  }
94
114
 
95
- #mergeRoute(routeId: string, delta: BuildRouteClientResult): void {
115
+ #mergeDelta(delta: BuildRouteClientResult): void {
96
116
  for (const [key, row] of Object.entries(delta.manifestDelta)) this.#merged.clientManifest[key] = row;
97
117
  for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap)) {
98
118
  this.#merged.ssrManifest.moduleMap[url] = byName;
99
119
  }
100
120
  for (const abs of delta.newEntries) this.#knownSet.add(abs);
101
- this.#routeIds.push(routeId);
102
121
  }
103
122
  }