@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/selectModel.ts CHANGED
@@ -3,9 +3,8 @@ import { select } from "@inquirer/prompts";
3
3
 
4
4
  export const selectModel = async (modulePath: string) => {
5
5
  const modelNames = readdirSync(`${modulePath}/lib`).filter((dir) => !dir.includes(".") && !dir.startsWith("_"));
6
- const modelName = await select({
6
+ return await select({
7
7
  message: "Select the model to create the unit for",
8
8
  choices: modelNames.map((name) => ({ name, value: name })),
9
9
  });
10
- return modelName;
11
10
  };
package/semver.ts CHANGED
@@ -1,14 +1,12 @@
1
- // `Bun.semver.order` compares range strings loosely — `^3.1049.0` vs `^3.721.0` answers 0 — so the
2
- // operator prefix has to come off before it sees the version.
1
+ // `Bun.semver.order` compares ranges loosely (`^3.1049.0` vs `^3.721.0` answers 0), so the operator comes off first.
3
2
  const stripRangeOperator = (version: string) => version.replace(/^[\s^~>=<v]+/, "").trim();
4
3
 
5
- const parseVersion = (version: string): number[] => {
6
- return version
4
+ const parseVersion = (version: string): number[] =>
5
+ version
7
6
  .replace(/^[^\d]*/, "")
8
7
  .split(/[.-]/)
9
8
  .map((part) => Number.parseInt(part, 10))
10
9
  .map((part) => (Number.isFinite(part) ? part : 0));
11
- };
12
10
 
13
11
  const compareNumeric = (a: string, b: string): number => {
14
12
  const left = parseVersion(a);
@@ -1,30 +1,13 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, rm } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
6
5
  import { formatSlicePlan, SlicePlanner } from "./slicePlanner";
6
+ import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
7
7
  import type { PackageJson } from "./types";
8
8
 
9
- const tempRoots: string[] = [];
10
- const originalEnv = { ...process.env };
11
-
12
- beforeEach(() => {
13
- process.env = { ...originalEnv };
14
- process.env.AKAN_PUBLIC_REPO_NAME = "workspace";
15
- process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
16
- process.env.AKAN_PUBLIC_ENV = "local";
17
- });
18
-
19
- afterEach(async () => {
20
- process.env = { ...originalEnv };
21
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
22
- });
23
-
24
- const write = async (filePath: string, content: string) => {
25
- await mkdir(path.dirname(filePath), { recursive: true });
26
- await writeFile(filePath, content);
27
- };
9
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "workspace", AKAN_PUBLIC_SERVE_DOMAIN: "example.com", AKAN_PUBLIC_ENV: "local" });
10
+ const makeTempRoot = tempDirs("akan-slice-");
28
11
 
29
12
  const gitignore = [
30
13
  "node_modules",
@@ -36,8 +19,7 @@ const gitignore = [
36
19
 
37
20
  // `AppExecutor.from` and `AppInfo.fromExecutor` both memoise by name, so each fixture needs a fresh one.
38
21
  const makeWorkspace = async (appName: string, libName: string) => {
39
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-slice-"));
40
- tempRoots.push(root);
22
+ const root = await makeTempRoot();
41
23
  const rootPackageJson: PackageJson = {
42
24
  name: "workspace",
43
25
  version: "0.0.1",
package/slicePlanner.ts CHANGED
@@ -18,12 +18,6 @@ export interface SlicePlan {
18
18
  warnings: string[];
19
19
  }
20
20
 
21
- /**
22
- * The exact file set a single app needs to live in a workspace of its own: the app, its transitive lib
23
- * closure, the workspace shell around them, and a root manifest for the result.
24
- *
25
- * Consumed by `akan plan-slice`, and by anything that moves an app or a lib between workspaces.
26
- */
27
21
  export class SlicePlanner {
28
22
  /** Root entries owned by an app, a lib or an in-tree package rather than by the workspace shell. */
29
23
  static readonly memberDirs = ["apps", "libs", "pkgs"];
@@ -39,13 +33,8 @@ export class SlicePlanner {
39
33
  ];
40
34
  static readonly akanPackages = ["akanjs", "@akanjs/devkit", "@akanjs/cli"];
41
35
 
42
- /**
43
- * What the framework imports but does not install, so a workspace root has to declare it: `react`,
44
- * `scheduler` and the RSC runtime, but also `ioredis`, `postgres` and `bullmq` the moment an app uses
45
- * a database or a queue. No source file in the workspace names any of them, so no scan can find them
46
- * — they are the akan packages' own `peerDependencies`, read wherever those are installed. Anything
47
- * they declare as a plain dependency is installed with them and needs no root entry.
48
- */
36
+ // The akan packages' peerDependencies (react, the RSC runtime, ioredis, postgres, bullmq): the framework imports
37
+ // them but no workspace source names them, so no scan can find them.
49
38
  static async #frameworkPeers(workspace: WorkspaceExecutor) {
50
39
  const peers = new Set<string>();
51
40
  let found = false;
@@ -67,14 +56,8 @@ export class SlicePlanner {
67
56
  return dependency.startsWith("@") ? `@types/${dependency.slice(1).replace("/", "__")}` : `@types/${dependency}`;
68
57
  }
69
58
 
70
- /**
71
- * The root manifest a workspace holding only these dependencies would carry, with the workspace's own
72
- * version specs kept — that is what makes one branch resolve to one dependency tree everywhere.
73
- *
74
- * A `@types/x` package is kept alongside `x`: nothing imports it by name, so no scan can see it, and
75
- * dropping it breaks the typecheck of every file that imports `x`. `workspaces` is dropped because it
76
- * names in-tree packages, which a slice never carries — it consumes akanjs from the registry.
77
- */
59
+ // `@types/x` is kept alongside `x` since nothing imports it by name; `workspaces` is dropped because a slice
60
+ // consumes akanjs from the registry.
78
61
  static async pruneDependencies(
79
62
  workspace: WorkspaceExecutor,
80
63
  rootPackageJson: PackageJson,
@@ -82,9 +65,8 @@ export class SlicePlanner {
82
65
  ) {
83
66
  const peers = await SlicePlanner.#frameworkPeers(workspace);
84
67
  const { workspaces: _workspaces, ...rest } = rootPackageJson;
85
- //* Without akanjs's peer list every peer looks unused, and pruning it produces an install that
86
- //* succeeds and a server that fails at its first redis or postgres call. Carry the dependencies
87
- //* whole instead and say why.
68
+ //* Without the peer list every peer looks unused, and pruning it breaks the server at its first redis or
69
+ //* postgres call.
88
70
  if (!peers)
89
71
  return {
90
72
  packageJson: rest as PackageJson,
@@ -121,10 +103,6 @@ export class SlicePlanner {
121
103
  this.#app = app;
122
104
  }
123
105
 
124
- async #trackedFiles(paths: string[]) {
125
- return await this.#app.workspace.listGitFiles(paths);
126
- }
127
-
128
106
  async #untrackedFiles(paths: string[]) {
129
107
  const [tracked, all] = await Promise.all([
130
108
  this.#app.workspace.listGitFiles(paths),
@@ -170,14 +148,16 @@ export class SlicePlanner {
170
148
  const slicePaths = [`apps/${this.#app.name}`, ...libs.map((lib) => `libs/${lib}`)];
171
149
 
172
150
  const [appFiles, allTracked, untracked, rootPackageJson] = await Promise.all([
173
- this.#trackedFiles([`apps/${this.#app.name}`]),
174
- this.#trackedFiles(["."]),
151
+ this.#app.workspace.listGitFiles([`apps/${this.#app.name}`]),
152
+ this.#app.workspace.listGitFiles(["."]),
175
153
  this.#untrackedFiles(slicePaths),
176
154
  this.#app.workspace.getPackageJson(),
177
155
  ]);
178
156
 
179
157
  const libFiles = Object.fromEntries(
180
- await Promise.all(libs.map(async (lib) => [lib, await this.#trackedFiles([`libs/${lib}`])] as const)),
158
+ await Promise.all(
159
+ libs.map(async (lib) => [lib, await this.#app.workspace.listGitFiles([`libs/${lib}`])] as const),
160
+ ),
181
161
  );
182
162
  const rootFiles = allTracked.filter((file) => !this.#isMemberFile(file));
183
163
 
@@ -211,7 +191,6 @@ export class SlicePlanner {
211
191
  }
212
192
  }
213
193
 
214
- /** Top-level entry each path sits under, so a long tree (`infra/**`) reads as one line. */
215
194
  const rootEntriesOf = (rootFiles: string[]) => [...new Set(rootFiles.map((file) => file.split("/")[0] ?? file))].sort();
216
195
 
217
196
  export function formatSlicePlan(plan: SlicePlan) {
package/spinner.test.ts CHANGED
@@ -6,10 +6,7 @@ interface TtyStub {
6
6
  rawModeCalls: boolean[];
7
7
  }
8
8
 
9
- /**
10
- * ora only reaches for stdin when both streams look interactive, so the stub has to fake a tty on
11
- * stderr (which decides `isEnabled`) as well as on stdin.
12
- */
9
+ // ora only reaches for stdin when both streams look interactive, so stderr (which decides `isEnabled`) is faked too.
13
10
  const stubTty = (): TtyStub => {
14
11
  const rawModeCalls: boolean[] = [];
15
12
  const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => unknown };
@@ -41,8 +38,7 @@ const stubTty = (): TtyStub => {
41
38
  };
42
39
 
43
40
  describe("Spinner", () => {
44
- // A raw terminal at spawn time is what a Bun child snapshots and writes back when it exits, which is
45
- // how `akan start` used to leave a terminal that no longer turns Ctrl+C into SIGINT.
41
+ // A Bun child snapshots a raw terminal at spawn and writes it back on exit, leaving Ctrl+C no longer a SIGINT.
46
42
  test("never puts the terminal into raw mode while it spins", () => {
47
43
  const tty = stubTty();
48
44
  try {
@@ -59,8 +55,7 @@ describe("Spinner", () => {
59
55
  expect(Spinner.oraOptions.discardStdin).toBe(false);
60
56
  });
61
57
 
62
- // An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an
63
- // infinite one — the process then writes cursor moves until it is SIGKILLed.
58
+ // An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an infinite one.
64
59
  test("refuses to animate against a tty that reports no width", () => {
65
60
  expect(Spinner.canAnimate({ isTTY: true, columns: 0 } as NodeJS.WriteStream)).toBe(false);
66
61
  expect(Spinner.canAnimate({ isTTY: true, columns: 120 } as NodeJS.WriteStream)).toBe(true);
package/spinner.ts CHANGED
@@ -2,23 +2,11 @@ import ora, { type Ora } from "ora";
2
2
 
3
3
  export class Spinner {
4
4
  static padding = 12;
5
- /**
6
- * XXX: `discardStdin` must stay off. It makes ora put the terminal into raw mode for as long as the
7
- * spinner runs, and a Bun child spawned in that window snapshots the raw termios and writes it back
8
- * when it exits — long after the spinner restored the terminal. `akan start` spawns the builder and
9
- * the backend under the "Preparing backend..." spinner, so the first builder recycle (a config or
10
- * runtime-metadata change) SIGTERMs that child and silently turns the developer's terminal raw:
11
- * `isig` goes off, Ctrl+C stops producing SIGINT at all, and the dev server looks unkillable.
12
- */
5
+ // XXX: `discardStdin` must stay off: ora raw-modes the terminal while spinning, a Bun child spawned then writes that
6
+ // raw termios back when it exits, and Ctrl+C stops producing SIGINT.
13
7
  static oraOptions = { discardStdin: false } as const;
14
- /**
15
- * ora sizes its clear loop as `ceil(lineWidth / stream.columns)`, so a tty that reports **0** columns
16
- * makes it `Infinity` and `clear()` never returns. An unsized pty does exactly that (`isTTY: true`,
17
- * `columns: 0`) — CI runners, `expect`/`script` harnesses, some detached panes. Measured: 750MB of
18
- * cursor moves and 8.7GB RSS inside a minute, in a loop that no longer reaches the point where SIGINT
19
- * or SIGTERM could be handled, so only SIGKILL ends it. A terminal with no width has nothing to
20
- * animate anyway; fall back to plain lines.
21
- */
8
+ // ora's clear loop is `ceil(lineWidth / columns)`, so an unsized pty (`isTTY` with 0 columns: CI, `script`) never
9
+ // returns from `clear()`; such a terminal gets plain lines.
22
10
  static canAnimate(stream: NodeJS.WriteStream = process.stderr): boolean {
23
11
  return !stream.isTTY || stream.columns > 0;
24
12
  }
package/sshTunnel.ts CHANGED
@@ -20,7 +20,6 @@ interface TunnelSocketData {
20
20
  closed: boolean;
21
21
  }
22
22
 
23
- type BunServer = { stop(): void };
24
23
  type BunSocket = Bun.Socket<TunnelSocketData>;
25
24
 
26
25
  const closeQuietly = (close: () => void) => {
@@ -126,7 +125,7 @@ export const createSshTunnel = async (options: SshTunnelOptions): Promise<SshTun
126
125
  closing = true;
127
126
  for (const socket of sockets) closeSocket(socket, error);
128
127
  closeQuietly(() => client.end());
129
- closeBunServer(server);
128
+ server.stop();
130
129
  };
131
130
 
132
131
  client.once("error", (error) => closeTunnel(error));
@@ -146,7 +145,3 @@ const closeBunSocket = (socket: BunSocket, error?: Error) => {
146
145
  else socket.data.stream?.end();
147
146
  closeQuietly(() => socket.end());
148
147
  };
149
-
150
- const closeBunServer = (server: BunServer) => {
151
- server.stop();
152
- };
@@ -3,12 +3,7 @@ import ts from "typescript";
3
3
  import type { SourceFileInfo } from "./qualityScanner";
4
4
  import { formatSsrBalance, SSR_SERVER_SHARE_TARGET, SsrScanner } from "./ssrScanner";
5
5
 
6
- /**
7
- * `SsrScanner` is a documented gate — `akan quality ssr` prints the server share and AGENTS.md names 50% as
8
- * the floor — so both halves matter: a rule that stops firing lets the share rot, and a rule that
9
- * over-reports is what makes developers stop reading the output. Every rule here gets a positive and the
10
- * negative that must stay quiet.
11
- */
6
+ //? Each rule gets a positive and a negative: one that stops firing lets the share rot, one that over-reports is ignored
12
7
  const fileOf = (file: string, content: string): SourceFileInfo => ({
13
8
  file,
14
9
  absolutePath: `/repo/${file}`,
package/ssrScanner.ts CHANGED
@@ -22,34 +22,32 @@ interface ComponentInfo {
22
22
  vendorTags: boolean;
23
23
  }
24
24
 
25
- // A component with no client-only touch at all never needed the client bundle, so even a small subtree is
26
- // worth moving. A mostly-static component keeps its interaction and hands the static part to the server, so
27
- // it only pays off once the static subtree is large enough to matter.
25
+ // A component with no client-only touch is worth moving even when small; a mostly-static one only once its static
26
+ // subtree is large enough to matter.
28
27
  const STATIC_COMPONENT_MIN_MASS = 4;
29
28
  const MIXED_COMPONENT_MIN_MASS = 10;
30
29
  const MIXED_COMPONENT_MAX_TOUCHES = 2;
31
30
  const MODULE_SERVER_VIEW_MIN_CLIENT_MASS = 12;
32
31
 
32
+ export const fileWarning = (
33
+ rule: string,
34
+ scope: QualityWarning["scope"],
35
+ file: string,
36
+ message: string,
37
+ line?: number,
38
+ ): QualityWarning =>
39
+ line === undefined
40
+ ? { rule, scope, severity: "warning", file, message }
41
+ : { rule, scope, severity: "warning", file, line, message };
42
+
33
43
  export class SsrScanner {
34
- // `usePage` and `getSelf` read request-scoped server context and are legal in server components, so they
35
- // must not count as evidence that a file needs "use client".
44
+ // `usePage` and `getSelf` read request-scoped server context, which server components may do.
36
45
  static #serverSafeCalls = new Set(["usePage", "getSelf", "useServer"]);
37
- static #clientGlobals = new Set([
38
- "window",
39
- "document",
40
- "navigator",
41
- "localStorage",
42
- "sessionStorage",
43
- "location",
44
- "history",
45
- "screen",
46
- "matchMedia",
47
- "IntersectionObserver",
48
- "ResizeObserver",
49
- "MutationObserver",
50
- "requestAnimationFrame",
51
- "WebSocket",
52
- ]);
46
+ static #clientGlobals = new Set(
47
+ "window document navigator localStorage sessionStorage location history screen matchMedia IntersectionObserver"
48
+ .split(" ")
49
+ .concat("ResizeObserver MutationObserver requestAnimationFrame WebSocket".split(" ")),
50
+ );
53
51
  // Runtime singletons that only exist in the client bundle; importing either is what forces the directive.
54
52
  static #clientRuntimeImports = new Set(["st", "fetch"]);
55
53
 
@@ -78,27 +76,29 @@ export class SsrScanner {
78
76
  this.#getTouches(sourceFile.sourceFile, sourceFile.sourceFile).length === 0 &&
79
77
  !this.#isConventionClientFile(sourceFile.file)
80
78
  ) {
81
- warnings.push({
82
- rule: "akan.ssr.unnecessary-use-client",
83
- scope: "ssr",
84
- severity: "warning",
85
- file: sourceFile.file,
86
- line: 1,
87
- message: `"use client" is declared but the file uses no client-only capability (hook, event handler, store, or browser API).`,
88
- });
79
+ warnings.push(
80
+ fileWarning(
81
+ "akan.ssr.unnecessary-use-client",
82
+ "ssr",
83
+ sourceFile.file,
84
+ `"use client" is declared but the file uses no client-only capability (hook, event handler, store, or browser API).`,
85
+ 1,
86
+ ),
87
+ );
89
88
  }
90
89
 
91
90
  for (const component of components) {
92
91
  if (component.vendorTags) continue;
93
92
  if (component.touches.length === 0 && component.mass >= STATIC_COMPONENT_MIN_MASS) {
94
- warnings.push({
95
- rule: "akan.ssr.client-static-component",
96
- scope: "ssr",
97
- severity: "warning",
98
- file: sourceFile.file,
99
- line: component.line,
100
- message: `Client component "${component.name}" renders ${component.mass} JSX elements with no client-only capability. It is server-renderable markup sitting in the client bundle.`,
101
- });
93
+ warnings.push(
94
+ fileWarning(
95
+ "akan.ssr.client-static-component",
96
+ "ssr",
97
+ sourceFile.file,
98
+ `Client component "${component.name}" renders ${component.mass} JSX elements with no client-only capability. It is server-renderable markup sitting in the client bundle.`,
99
+ component.line,
100
+ ),
101
+ );
102
102
  continue;
103
103
  }
104
104
  if (
@@ -106,14 +106,15 @@ export class SsrScanner {
106
106
  component.touches.length <= MIXED_COMPONENT_MAX_TOUCHES &&
107
107
  component.mass >= MIXED_COMPONENT_MIN_MASS
108
108
  ) {
109
- warnings.push({
110
- rule: "akan.ssr.client-static-markup",
111
- scope: "ssr",
112
- severity: "warning",
113
- file: sourceFile.file,
114
- line: component.line,
115
- message: `Client component "${component.name}" renders ${component.mass} JSX elements around only ${component.touches.length} client-only touch (${[...new Set(component.touches)].join(", ")}). Most of this subtree does not need the client bundle.`,
116
- });
109
+ warnings.push(
110
+ fileWarning(
111
+ "akan.ssr.client-static-markup",
112
+ "ssr",
113
+ sourceFile.file,
114
+ `Client component "${component.name}" renders ${component.mass} JSX elements around only ${component.touches.length} client-only touch (${[...new Set(component.touches)].join(", ")}). Most of this subtree does not need the client bundle.`,
115
+ component.line,
116
+ ),
117
+ );
117
118
  }
118
119
  }
119
120
 
@@ -122,8 +123,7 @@ export class SsrScanner {
122
123
  return warnings;
123
124
  }
124
125
 
125
- // A database module whose rendering happens entirely in Template/Zone/Util has no server-rendered surface at
126
- // all, so every consumer pays for hydration even when it only needs to display the model.
126
+ // A module rendered only from Template/Zone/Util makes every consumer pay for hydration just to display it.
127
127
  #scanModules(sourceFiles: SourceFileInfo[]): QualityWarning[] {
128
128
  const modules = new Map<string, { clientMass: number; serverFiles: number; line: string }>();
129
129
  for (const sourceFile of sourceFiles) {
@@ -136,36 +136,32 @@ export class SsrScanner {
136
136
  }
137
137
  return [...modules]
138
138
  .filter(([, entry]) => entry.serverFiles === 0 && entry.clientMass >= MODULE_SERVER_VIEW_MIN_CLIENT_MASS)
139
- .map(([moduleDir, entry]) => ({
140
- rule: "akan.ssr.module-missing-server-view",
141
- scope: "ssr" as const,
142
- severity: "warning" as const,
143
- file: entry.line,
144
- message: `Module "${moduleDir}" renders ${entry.clientMass} JSX elements from client files only; it declares no Unit or View server component.`,
145
- }));
139
+ .map(([moduleDir, entry]) =>
140
+ fileWarning(
141
+ "akan.ssr.module-missing-server-view",
142
+ "ssr",
143
+ entry.line,
144
+ `Module "${moduleDir}" renders ${entry.clientMass} JSX elements from client files only; it declares no Unit or View server component.`,
145
+ ),
146
+ );
146
147
  }
147
148
 
148
- // A load fired from a mount-only effect is data the route already could have fetched: the client renders an
149
- // empty shell, hydrates, then fetches. A reactive effect (non-empty deps) responds to client state instead
150
- // and has no server-side equivalent, so only the empty-dependency form is a finding.
149
+ // Only an empty-dependency effect is a finding: a reactive effect answers client state and has no server equivalent.
151
150
  #getMountLoadWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
152
151
  const warnings: QualityWarning[] = [];
153
- const visit = (node: ts.Node) => {
154
- if (this.#isMountEffect(sourceFile.sourceFile, node)) {
155
- for (const load of this.#getLoadCalls(sourceFile.sourceFile, node)) {
156
- warnings.push({
157
- rule: "akan.ssr.client-mount-load",
158
- scope: "ssr",
159
- severity: "warning",
160
- file: sourceFile.file,
161
- line: this.#getLine(sourceFile.sourceFile, load.node),
162
- message: `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
163
- });
164
- }
165
- }
166
- ts.forEachChild(node, visit);
167
- };
168
- ts.forEachChild(sourceFile.sourceFile, visit);
152
+ SsrScanner.#walk(sourceFile.sourceFile, (node) => {
153
+ if (!this.#isMountEffect(sourceFile.sourceFile, node)) return;
154
+ for (const load of this.#getLoadCalls(sourceFile.sourceFile, node))
155
+ warnings.push(
156
+ fileWarning(
157
+ "akan.ssr.client-mount-load",
158
+ "ssr",
159
+ sourceFile.file,
160
+ `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
161
+ this.#getLine(sourceFile.sourceFile, load.node),
162
+ ),
163
+ );
164
+ });
169
165
  return warnings;
170
166
  }
171
167
 
@@ -179,35 +175,30 @@ export class SsrScanner {
179
175
 
180
176
  #getLoadCalls(sourceFile: ts.SourceFile, node: ts.Node) {
181
177
  const calls: Array<{ callee: string; node: ts.Node }> = [];
182
- const visit = (child: ts.Node) => {
183
- if (ts.isCallExpression(child)) {
184
- const callee = child.expression.getText(sourceFile);
185
- if (/^fetch\.[a-z]/.test(callee) || /^st\.do\.(init|get|view|load|list|count|insight)[A-Z]/.test(callee))
186
- calls.push({ callee, node: child });
187
- }
188
- ts.forEachChild(child, visit);
189
- };
190
- ts.forEachChild(node, visit);
178
+ SsrScanner.#walk(node, (child) => {
179
+ if (!ts.isCallExpression(child)) return;
180
+ const callee = child.expression.getText(sourceFile);
181
+ if (/^fetch\.[a-z]/.test(callee) || /^st\.do\.(init|get|view|load|list|count|insight)[A-Z]/.test(callee))
182
+ calls.push({ callee, node: child });
183
+ });
191
184
  return calls;
192
185
  }
193
186
 
194
187
  #getTemplateStateWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
195
188
  if (!sourceFile.file.endsWith(".Template.tsx")) return [];
196
189
  const warnings: QualityWarning[] = [];
197
- const visit = (node: ts.Node) => {
198
- if (ts.isCallExpression(node) && node.expression.getText(sourceFile.sourceFile) === "useState") {
199
- warnings.push({
200
- rule: "akan.ssr.template-client-state",
201
- scope: "ssr",
202
- severity: "warning",
203
- file: sourceFile.file,
204
- line: this.#getLine(sourceFile.sourceFile, node),
205
- message: "Template holds form state in useState. Templates are store-driven and carry no local state.",
206
- });
207
- }
208
- ts.forEachChild(node, visit);
209
- };
210
- ts.forEachChild(sourceFile.sourceFile, visit);
190
+ SsrScanner.#walk(sourceFile.sourceFile, (node) => {
191
+ if (ts.isCallExpression(node) && node.expression.getText(sourceFile.sourceFile) === "useState")
192
+ warnings.push(
193
+ fileWarning(
194
+ "akan.ssr.template-client-state",
195
+ "ssr",
196
+ sourceFile.file,
197
+ "Template holds form state in useState. Templates are store-driven and carry no local state.",
198
+ this.#getLine(sourceFile.sourceFile, node),
199
+ ),
200
+ );
201
+ });
211
202
  return warnings;
212
203
  }
213
204
 
@@ -261,7 +252,7 @@ export class SsrScanner {
261
252
 
262
253
  #getTouches(sourceFile: ts.SourceFile, node: ts.Node): string[] {
263
254
  const touches: string[] = [];
264
- const visit = (child: ts.Node) => {
255
+ SsrScanner.#walk(node, (child) => {
265
256
  if (ts.isCallExpression(child)) {
266
257
  const callee = child.expression.getText(sourceFile);
267
258
  const bareName = callee.split(".").pop() ?? callee;
@@ -275,41 +266,42 @@ export class SsrScanner {
275
266
  if (root === "st") touches.push("st");
276
267
  else if (SsrScanner.#clientGlobals.has(root)) touches.push(root);
277
268
  }
278
- ts.forEachChild(child, visit);
279
- };
280
- ts.forEachChild(node, visit);
269
+ });
281
270
  return touches;
282
271
  }
283
272
 
284
273
  #getTagNames(sourceFile: ts.SourceFile, node: ts.Node): Set<string> {
285
274
  const tags = new Set<string>();
286
- const visit = (child: ts.Node) => {
275
+ SsrScanner.#walk(node, (child) => {
287
276
  if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child))
288
277
  tags.add(child.tagName.getText(sourceFile).split(".")[0]);
289
- ts.forEachChild(child, visit);
290
- };
291
- ts.forEachChild(node, visit);
278
+ });
292
279
  return tags;
293
280
  }
294
281
 
295
282
  #getMass(node: ts.Node) {
296
283
  let mass = 0;
297
- const visit = (child: ts.Node) => {
284
+ SsrScanner.#walk(node, (child) => {
298
285
  if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child)) mass += 1;
299
- ts.forEachChild(child, visit);
300
- };
301
- ts.forEachChild(node, visit);
286
+ });
302
287
  return mass;
303
288
  }
304
289
 
290
+ static #walk(node: ts.Node, visit: (descendant: ts.Node) => void) {
291
+ const walk = (child: ts.Node) => {
292
+ visit(child);
293
+ ts.forEachChild(child, walk);
294
+ };
295
+ ts.forEachChild(node, walk);
296
+ }
297
+
305
298
  #hasUseClient(sourceFile: ts.SourceFile) {
306
299
  const first = sourceFile.statements[0];
307
300
  if (!first || !ts.isExpressionStatement(first) || !ts.isStringLiteral(first.expression)) return false;
308
301
  return first.expression.text === "use client";
309
302
  }
310
303
 
311
- // A bare specifier is a third-party package: it may be client-only, which is a legitimate reason for the
312
- // directive that no amount of AST reading can rule out.
304
+ // A third-party package may be client-only, a legitimate reason for the directive no AST reading can rule out.
313
305
  #hasVendorImport(sourceFile: ts.SourceFile) {
314
306
  return sourceFile.statements.some(
315
307
  (statement) => ts.isImportDeclaration(statement) && isVendorSpecifier(getSpecifier(statement)),
@@ -340,9 +332,7 @@ export class SsrScanner {
340
332
  return false;
341
333
  }
342
334
 
343
- // Zone/Template/Util carry the directive mechanically by file role, and `index_.tsx` is the declared
344
- // "use client" + lazy() boundary. In neither case is the directive a stray — for module UI it means markup
345
- // belongs in a Unit or View instead, which the component rules already cover.
335
+ // Zone/Template/Util carry the directive by file role, and `index_.tsx` is the declared lazy() boundary.
346
336
  #isConventionClientFile(file: string) {
347
337
  if (file.endsWith("/index_.tsx")) return true;
348
338
  return /\.(Zone|Template|Util)\.tsx$/.test(file) && this.#getModuleDir(file) !== null;
package/subspace.test.ts CHANGED
@@ -1,34 +1,17 @@
1
- import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test";
2
- import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, setDefaultTimeout, test } from "bun:test";
2
+ import { mkdir, readdir, rm } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { Executor, WorkspaceExecutor } from "./executors";
6
5
  import { FileSys } from "./fileSys";
7
6
  import { formatSubspaceDiff, formatSubspacePushResults, formatSubspaceStatuses, Subspace } from "./subspace";
8
7
  import { SubspaceConfig } from "./subspaceConfig";
8
+ import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
9
9
 
10
10
  //? Each case drives several real git clones and pushes; on a 4-core Windows VM that alone lands at 2-5s.
11
11
  setDefaultTimeout(20_000);
12
12
 
13
- const tempRoots: string[] = [];
14
- const originalEnv = { ...process.env };
15
-
16
- beforeEach(() => {
17
- process.env = { ...originalEnv };
18
- process.env.AKAN_PUBLIC_REPO_NAME = "workspace";
19
- process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
20
- process.env.AKAN_PUBLIC_ENV = "local";
21
- });
22
-
23
- afterEach(async () => {
24
- process.env = { ...originalEnv };
25
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
26
- });
27
-
28
- const write = async (filePath: string, content: string) => {
29
- await mkdir(path.dirname(filePath), { recursive: true });
30
- await writeFile(filePath, content);
31
- };
13
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "workspace", AKAN_PUBLIC_SERVE_DOMAIN: "example.com", AKAN_PUBLIC_ENV: "local" });
14
+ const makeTempRoot = tempDirs("akan-subspace-");
32
15
 
33
16
  const git = async (cwd: string, args: string[]) =>
34
17
  await new Executor("fixture", cwd).spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args]);
@@ -67,10 +50,8 @@ const makeSys = async (root: string, member: "apps" | "libs", name: string, extr
67
50
  await write(path.join(root, member, name, relative), content);
68
51
  };
69
52
 
70
- /** A workspace with two apps, one shared library, and a subspace that serves only the first app. */
71
53
  const makeMirror = async (servedApp: string, privateApp: string, libName: string) => {
72
- const workRoot = await mkdtemp(path.join(os.tmpdir(), "akan-subspace-"));
73
- tempRoots.push(workRoot);
54
+ const workRoot = await makeTempRoot();
74
55
  const wsRoot = path.join(workRoot, "workspace");
75
56
  const bareRoot = path.join(workRoot, "subspace.git");
76
57
  await mkdir(wsRoot, { recursive: true });