@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/subspace.ts CHANGED
@@ -75,15 +75,8 @@ export interface SubspacePullResult {
75
75
  incoming: SubspaceIncomingCommit[];
76
76
  }
77
77
 
78
- /**
79
- * One customer repo, mirrored from this workspace.
80
- *
81
- * Push is a squashed snapshot of the workspace's own tracked files, so a subspace's history never
82
- * carries the workspace's — which is also what keeps one customer's commit messages out of another's
83
- * repo. Pull is the reverse and rare: it compares the subspace against the last push it received rather
84
- * than against the workspace, so the diff is exactly the customer's own work however far the workspace
85
- * has moved on.
86
- */
78
+ // Push is a squashed snapshot, so no workspace history (or other customer's commit message) reaches a subspace;
79
+ // pull diffs against the last push received, so it is exactly the customer's own work.
87
80
  export class Subspace {
88
81
  static readonly anchorFile = "akan.subspace.json";
89
82
  /** Never reaches a subspace: it names every other customer's repo. */
@@ -133,7 +126,6 @@ export class Subspace {
133
126
  return (await this.#git(["rev-parse", "--short", "HEAD"])).trim();
134
127
  }
135
128
 
136
- /** Adds the subspace as a remote if absent, then fetches the branch into this workspace's object store. */
137
129
  async fetch(branch: string) {
138
130
  const remotes = (await this.#git(["remote"])).split("\n").map((line) => line.trim());
139
131
  if (remotes.includes(this.#remote)) await this.#git(["remote", "set-url", this.#remote, this.#declaration.repo]);
@@ -147,10 +139,7 @@ export class Subspace {
147
139
  }
148
140
  }
149
141
 
150
- /**
151
- * The last push, located by the one file only a push writes. A tag or a recorded sha would have to be
152
- * kept in step by hand; this file is already in the subspace's history and cannot drift out of it.
153
- */
142
+ //* Located by the one file only a push writes: unlike a tag or a recorded sha, it cannot drift out of the history.
154
143
  async anchor(branch: string): Promise<SubspaceAnchor> {
155
144
  const ref = `${this.#remote}/${branch}`;
156
145
  const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", Subspace.anchorFile])).trim();
@@ -184,11 +173,7 @@ export class Subspace {
184
173
  return Subspace.subspaceOwnedDirs.includes(segments[2] ?? "");
185
174
  }
186
175
 
187
- /**
188
- * A library manifest always differs: the subspace's copy carries the `akan.source` stamp a push writes and
189
- * the workspace's does not. Comparing it with that key removed is what keeps `status` and `diff` from
190
- * reporting every library as changed forever.
191
- */
176
+ // A library manifest always differs by the `akan.source` stamp a push writes, so it is compared without that key.
192
177
  #isStamped(file: string) {
193
178
  return /^libs\/[^/]+\/package\.json$/.test(file);
194
179
  }
@@ -201,9 +186,7 @@ export class Subspace {
201
186
  if (!theirs || !ours) return true;
202
187
  const normalize = (content: string) => {
203
188
  try {
204
- const manifest = JSON.parse(content) as Record<string, unknown>;
205
- delete manifest[LibSource.manifestKey];
206
- return JSON.stringify(manifest);
189
+ return LibSource.unstamped(content);
207
190
  } catch {
208
191
  return content;
209
192
  }
@@ -211,7 +194,10 @@ export class Subspace {
211
194
  return normalize(theirs) !== normalize(ours);
212
195
  }
213
196
 
214
- async #meaningfulFiles(ref: string, files: string[]) {
197
+ async #changedSliceFiles(ref: string, diffArgs: string[]) {
198
+ const files = (await this.#git(["diff", "--name-only", ...diffArgs]))
199
+ .split("\n")
200
+ .filter((file) => !!file.trim() && !this.#isSubspaceOwned(file));
215
201
  const kept = await Promise.all(
216
202
  files.map(async (file) => (this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true)),
217
203
  );
@@ -226,14 +212,7 @@ export class Subspace {
226
212
  ]);
227
213
  if (!hasBranch) return { name: this.name, branch, hasBranch, anchor, behindPaths: [], driftedLibs: [] };
228
214
  const ref = `${this.#remote}/${branch}`;
229
- const diff = await this.#git(["diff", "--name-only", "HEAD", ref, "--", ...paths]);
230
- const behindPaths = await this.#meaningfulFiles(
231
- ref,
232
- diff
233
- .split("\n")
234
- .filter((file) => !!file.trim())
235
- .filter((file) => !this.#isSubspaceOwned(file)),
236
- );
215
+ const behindPaths = await this.#changedSliceFiles(ref, ["HEAD", ref, "--", ...paths]);
237
216
  const driftedLibs = libs.filter((lib) => behindPaths.some((file) => file.startsWith(`libs/${lib}/`)));
238
217
  return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
239
218
  }
@@ -273,11 +252,7 @@ export class Subspace {
273
252
  return held;
274
253
  }
275
254
 
276
- /**
277
- * Puts back only the files the workspace does not ship. The workspace still owns the tracked env switch files
278
- * (`env.server.ts` / `env.client.ts`), which is what keeps them in step; everything else under `env/` —
279
- * the per-environment values, which the workspace gitignores and never had — belongs to the subspace and wins.
280
- */
255
+ // The workspace owns the tracked env switch files; the per-environment values it gitignores belong to the subspace.
281
256
  async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
282
257
  for (const { from, to } of held) await Subspace.#copyMissing(to, from);
283
258
  await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
@@ -293,12 +268,7 @@ export class Subspace {
293
268
  }
294
269
  }
295
270
 
296
- /**
297
- * `git archive` emits exactly the tracked files at HEAD, which is why the copy needs no exclude list of
298
- * its own: generated barrels, the `(libs)`/`public/libs` symlinks, env values, secrets and the lockfile
299
- * are all outside git and cannot enter the archive. Spawned without a shell, whose quoting differs between
300
- * `sh` and `cmd.exe`.
301
- */
271
+ // `git archive` emits only tracked files, so generated barrels, lib symlinks, env values and secrets never enter.
302
272
  async #extract(paths: string[], excludes: string[] = []) {
303
273
  // Relative, because GNU tar reads a drive-letter archive name (`C:\…`) as `host:path` on a remote host.
304
274
  const archivePath = path.relative(this.#workspace.workspaceRoot, `${this.#clonePath}.tar`);
@@ -317,11 +287,7 @@ export class Subspace {
317
287
  }
318
288
  }
319
289
 
320
- /**
321
- * Verify-only, and an allowlist rather than a filter so no secret can reach a customer's clone even by
322
- * accident. `AKAN_PUBLIC_*` values are the ones akan embeds in every client bundle, so they are public
323
- * by construction; the cloud workspace id is pinned to `local` rather than copied.
324
- */
290
+ // An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
325
291
  #localEnv() {
326
292
  const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
327
293
  return {
@@ -334,13 +300,8 @@ export class Subspace {
334
300
  };
335
301
  }
336
302
 
337
- /**
338
- * The CLI identifies a workspace root by `package.json` + `tsconfig.json` + `.env`, so a clone with no
339
- * `.env` cannot run `akan sync` at all — and one can never arrive with the slice, since every akan
340
- * workspace gitignores it and the subspace's real values are its own. Excluded through the clone's
341
- * `.git/info/exclude` rather than through the copied `.gitignore`: a workspace that omitted the pattern
342
- * would otherwise commit this file into the customer's repo.
343
- */
303
+ // The CLI needs a `.env` to recognise a workspace root and none arrives with the slice. Excluded through
304
+ // `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
344
305
  async #writeLocalEnv() {
345
306
  const excludePath = path.join(this.#clonePath, ".git/info/exclude");
346
307
  const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
@@ -354,11 +315,7 @@ export class Subspace {
354
315
  await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
355
316
  }
356
317
 
357
- /**
358
- * A workspace's root manifest is the union of every app it holds, so shipping it verbatim installs
359
- * every other customer's dependency tree in this repo. It is rebuilt from this subspace's own slices
360
- * instead, keeping the workspace's exact version specs.
361
- */
318
+ // The root manifest is the union of every app's dependencies, so it is rebuilt from this subspace's slices.
362
319
  async #rewriteManifest(plans: SlicePlan[]) {
363
320
  const manifestPath = path.join(this.#clonePath, "package.json");
364
321
  if (!(await FileSys.fileExists(manifestPath))) return [];
@@ -383,8 +340,7 @@ export class Subspace {
383
340
  for (const member of Subspace.memberDirs)
384
341
  await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
385
342
 
386
- //* The shell is overlaid, never reconciled: a subspace owns its deployment (its own CI files and
387
- //* workflows live there), so a root entry the workspace does not have is left alone rather than deleted.
343
+ //* Overlaid, never reconciled: a subspace owns its deployment, so a root entry the workspace lacks is left alone.
388
344
  await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
389
345
  await this.#extract(paths);
390
346
  await this.#restoreSubspaceOwned(held);
@@ -400,11 +356,7 @@ export class Subspace {
400
356
  return { libs, pruned };
401
357
  }
402
358
 
403
- /**
404
- * The `akan:secrets` block akan generates lists every app in the workspace by name, so it is filtered down to
405
- * this subspace's own apps. `bun.lock` is un-ignored because a subspace commits its lockfile — that is what
406
- * makes two subspaces on one branch resolve the same dependency tree rather than merely the same ranges.
407
- */
359
+ // `akan:secrets` names every workspace app, so it is filtered; `bun.lock` is un-ignored because a subspace commits it.
408
360
  async #rewriteGitignore() {
409
361
  const gitignorePath = path.join(this.#clonePath, ".gitignore");
410
362
  if (!(await FileSys.fileExists(gitignorePath))) return;
@@ -442,12 +394,8 @@ export class Subspace {
442
394
  }
443
395
  }
444
396
 
445
- /**
446
- * Written onto the clone's manifest directly rather than through `LibSource`, which addresses a library
447
- * by its position in the *workspace's* workspace. The hash follows the same rule — the library's own git files
448
- * with `env/` and the stamp itself left out — and is only written when it would change, so an
449
- * up-to-date subspace stays clean and the push is skipped.
450
- */
397
+ // Not through `LibSource`, which addresses a library by its position in the workspace. The hash skips `env/` and
398
+ // the stamp itself.
451
399
  async #stampLib(clone: Executor, lib: string, branch: string) {
452
400
  const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
453
401
  if (!(await FileSys.fileExists(manifestPath))) return;
@@ -456,9 +404,8 @@ export class Subspace {
456
404
  const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
457
405
  const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
458
406
  const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
459
- //* The extraction overwrote the manifest with the workspace's, stamp and all, so the previous stamp has to
460
- //* come from the clone's HEAD. Reusing its `syncedAt` when nothing else moved is what leaves the file
461
- //* byte-identical to what is committed — otherwise every push is dirty and none is ever skipped.
407
+ //* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD; reusing its
408
+ //* `syncedAt` when nothing moved keeps the file byte-identical, or no push would ever be skipped.
462
409
  const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
463
410
  const syncedAt = unchanged ? previous.syncedAt : new Date().toISOString();
464
411
  manifest[LibSource.manifestKey] = { ...akan, source: { origin, sha, hash, syncedAt } };
@@ -491,26 +438,15 @@ export class Subspace {
491
438
  .split("\0")
492
439
  .filter((file) => !!file && !this.#isSubspaceOwned(file))
493
440
  .sort();
494
- //* `--cached` reads the clone's index, which the slice replaced the members without updating — so it still
495
- //* names every file the workspace has deleted or moved since the last push, and opening one throws. The
496
- //* index is only reconciled by the `git add -A` a push commits with, long after the stamp is written.
441
+ //* The index still lists files the workspace deleted since the last push (only the push's `git add -A`
442
+ //* reconciles it), and opening one throws.
497
443
  const present = await Promise.all(
498
444
  candidates.map(async (file) => await FileSys.entryExists(path.join(this.#clonePath, file))),
499
445
  );
500
446
  const files = candidates.filter((_, index) => present[index]);
501
- const hasher = new Bun.CryptoHasher("sha256");
502
- for (const file of files) {
503
- const content = await FileSys.readText(path.join(this.#clonePath, file));
504
- hasher.update(file);
505
- hasher.update("\0");
506
- if (file === `libs/${lib}/package.json`) {
507
- const manifest = JSON.parse(content) as Record<string, unknown>;
508
- delete manifest[LibSource.manifestKey];
509
- hasher.update(JSON.stringify(manifest));
510
- } else hasher.update(content);
511
- hasher.update("\0");
512
- }
513
- return hasher.digest("hex").slice(0, 32);
447
+ return await LibSource.hashFiles(files, `libs/${lib}/package.json`, (file) =>
448
+ FileSys.readText(path.join(this.#clonePath, file)),
449
+ );
514
450
  }
515
451
 
516
452
  /** Direction is subspace → workspace, so the patch reads as what a push would apply rather than its inverse. */
@@ -530,25 +466,14 @@ export class Subspace {
530
466
  const scoped = filter ? paths.filter((entry) => entry.startsWith(filter) || filter.startsWith(entry)) : paths;
531
467
  if (!scoped.length) return { files: [], patch: "" };
532
468
  const from = `${this.#remote}/${branch}`;
533
- const pathspec = filter ? [filter] : scoped;
534
- const names = await this.#git(["diff", "--name-only", from, "HEAD", "--", ...pathspec]);
535
- const files = await this.#meaningfulFiles(
536
- from,
537
- names
538
- .split("\n")
539
- .filter((file) => !!file.trim())
540
- .filter((file) => !this.#isSubspaceOwned(file)),
541
- );
469
+ const files = await this.#changedSliceFiles(from, [from, "HEAD", "--", ...(filter ? [filter] : scoped)]);
542
470
  if (!files.length) return { files: [], patch: "" };
543
471
  //? argv, not a shell: a route path such as `page/(docs)/…` would need quoting through one.
544
472
  return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
545
473
  }
546
474
 
547
- /**
548
- * Refuses the subspace rather than throwing, so one customer repo that fails to install or sync does not
549
- * abort the push to the rest of the subspaces. The env is passed explicitly so the child sees the values
550
- * written into the clone instead of inheriting the workspace's own.
551
- */
475
+ // Refuses rather than throws, so one failing subspace does not abort the rest; the env is explicit so the child
476
+ // sees the clone's values, not the workspace's.
552
477
  async #verify(clone: Executor) {
553
478
  const env = { ...process.env, ...this.#localEnv() };
554
479
  try {
@@ -639,10 +564,7 @@ export class Subspace {
639
564
  await this.#git(["apply", "--3way", absolute]);
640
565
  }
641
566
 
642
- /**
643
- * A library hunk is never applied by default: the workspace is the one copy every other subspace is pushed from,
644
- * so adopting one customer's edit silently would ship it to all of them.
645
- */
567
+ // Not applied by default: every subspace is pushed from the workspace, so one customer's lib edit would ship to all.
646
568
  async #savePatch(range: string, files: string[], label: string) {
647
569
  const patchPath = path.join(this.#workspace.workspaceRoot, ".akan/subspace", `${this.name}-${label}.patch`);
648
570
  await mkdir(path.dirname(patchPath), { recursive: true });
package/subspaceConfig.ts CHANGED
@@ -5,13 +5,7 @@ import { FileSys } from "./fileSys";
5
5
 
6
6
  export type { SubspaceConfigInput, SubspaceDeclaration };
7
7
 
8
- /**
9
- * `akan.subspace.ts` at the workspace root: which customer repo each app is mirrored to.
10
- *
11
- * There is no branch field. The branch is whichever one the workspace is on, so the same declaration
12
- * serves `develop` and `main`, and every subspace on one branch holds the same akanjs version and the
13
- * same library source.
14
- */
8
+ // No branch field: the workspace's current branch is used, so every subspace on it holds the same akanjs and libs.
15
9
  export class SubspaceConfig {
16
10
  static readonly fileName = "akan.subspace.ts";
17
11
  static readonly defaultPushableBranches = ["main", "develop", "debug"];
@@ -48,8 +42,7 @@ export class SubspaceConfig {
48
42
  throw new Error(`${SubspaceConfig.fileName}: subspace "${subspace.name}" declares no apps`);
49
43
  for (const app of subspace.apps) {
50
44
  const owner = appOwners.get(app);
51
- //* One app never goes to two subspaces: the two would need per-subspace domains and ids, which
52
- //* this declaration cannot express. Share code through a lib instead.
45
+ //* One app never goes to two subspaces: per-subspace domains and ids are not expressible. Share a lib instead.
53
46
  if (owner)
54
47
  throw new Error(
55
48
  `${SubspaceConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${subspace.name}"`,
@@ -58,8 +51,7 @@ export class SubspaceConfig {
58
51
  }
59
52
  if (!subspace.workspaceId) continue;
60
53
  const idOwner = idOwners.get(subspace.workspaceId);
61
- //* An env upload replaces the cloud workspace's archive whole, so two subspaces sharing one would
62
- //* each overwrite the other's values.
54
+ //* An env upload replaces the cloud workspace's archive whole, so two subspaces would overwrite each other.
63
55
  if (idOwner)
64
56
  throw new Error(
65
57
  `${SubspaceConfig.fileName}: subspaces "${idOwner}" and "${subspace.name}" declare the same workspaceId`,
package/testHelpers.ts CHANGED
@@ -1,7 +1,11 @@
1
+ import { afterEach, beforeEach } from "bun:test";
1
2
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
- import { AppExecutor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
5
+
6
+ // Executors are imported on first use: they load akanjs/base, which patches the String/Boolean/Date globals.
7
+ const executors = () => import("@akanjs/devkit/executors");
8
+
5
9
  export interface CallRecord {
6
10
  name: string;
7
11
  args: unknown[];
@@ -11,11 +15,7 @@ export const createCallRecorder = () => {
11
15
  const calls: CallRecord[] = [];
12
16
  return {
13
17
  calls,
14
- /**
15
- * `Returns` lets one recorder stand in for methods that return a value. Nothing is produced — the
16
- * stub resolves to `undefined`, so use it only where the code under test does not read the result;
17
- * a test that needs the value should return it from its own closure instead.
18
- */
18
+ /** `Returns` only types the stub: it always returns `undefined`, so use it where the result is not read. */
19
19
  record<Returns = void>(name: string, ...args: unknown[]): Returns {
20
20
  calls.push({ name, args });
21
21
  return undefined as unknown as Returns;
@@ -72,20 +72,45 @@ export const createFakeExecutor = <Extra extends object = object>(
72
72
  scanSync: (...args: unknown[]) => Promise<{ name: string }>;
73
73
  };
74
74
 
75
- export const makeCliTempWorkspace = async () => {
76
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-cli-"));
77
- await mkdir(root, { recursive: true });
75
+ export const makeCliTempWorkspace = async (parentDir = os.tmpdir()) => {
76
+ await mkdir(parentDir, { recursive: true });
77
+ const root = await mkdtemp(path.join(parentDir, "akan-cli-"));
78
78
  await writeText(path.join(root, ".gitignore"), "");
79
79
  await writeText(
80
80
  path.join(root, ".env"),
81
81
  ["AKAN_PUBLIC_REPO_NAME=repo", "AKAN_PUBLIC_SERVE_DOMAIN=localhost", "AKAN_PUBLIC_ENV=local", ""].join("\n"),
82
82
  );
83
+ const { WorkspaceExecutor } = await executors();
83
84
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
84
85
  return { root, workspace };
85
86
  };
86
87
 
87
- export const cleanupCliTempWorkspace = async (root: string) => {
88
- await rm(root, { recursive: true, force: true });
88
+ // Registers the afterEach that removes the root of every value the returned tracker was handed.
89
+ export const tempRoots = (remove = (root: string) => rm(root, { recursive: true, force: true })) => {
90
+ const roots: string[] = [];
91
+ afterEach(async () => {
92
+ await Promise.all(roots.splice(0).map((root) => remove(root)));
93
+ });
94
+ return <Temp extends { root: string }>(temp: Temp) => {
95
+ roots.push(temp.root);
96
+ return temp;
97
+ };
98
+ };
99
+
100
+ // Registers the afterEach that removes every directory the returned factory created.
101
+ export const tempDirs = (prefix: string, remove?: (root: string) => Promise<void>) => {
102
+ const track = tempRoots(remove);
103
+ return async () => track({ root: await mkdtemp(path.join(os.tmpdir(), prefix)) }).root;
104
+ };
105
+
106
+ export const isolateEnv = (env: Record<string, string> = {}) => {
107
+ const originalEnv = { ...process.env };
108
+ beforeEach(() => {
109
+ process.env = { ...originalEnv, ...env };
110
+ });
111
+ afterEach(() => {
112
+ process.env = { ...originalEnv };
113
+ });
89
114
  };
90
115
 
91
116
  export const writeText = async (filePath: string, content: string) => {
@@ -97,8 +122,10 @@ export const writeJson = async (filePath: string, value: object) => {
97
122
  await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
98
123
  };
99
124
 
100
- export const createTempApp = async (appName = "demo") => {
101
- const { root, workspace } = await makeCliTempWorkspace();
125
+ const tsconfigJson = { compilerOptions: { target: "ESNext", paths: {} } };
126
+
127
+ export const createTempApp = async (appName = "demo", parentDir?: string) => {
128
+ const { root, workspace } = await makeCliTempWorkspace(parentDir);
102
129
  await writeJson(path.join(root, "package.json"), {
103
130
  name: "repo",
104
131
  version: "1.0.0",
@@ -109,13 +136,8 @@ export const createTempApp = async (appName = "demo") => {
109
136
  "react-server-dom-webpack": "19.0.0",
110
137
  },
111
138
  });
112
- await writeJson(path.join(root, "tsconfig.json"), {
113
- compilerOptions: { target: "ESNext", paths: {} },
114
- references: [],
115
- });
116
- await writeJson(path.join(root, "apps", appName, "tsconfig.json"), {
117
- compilerOptions: { target: "ESNext", paths: {} },
118
- });
139
+ await writeJson(path.join(root, "tsconfig.json"), { ...tsconfigJson, references: [] });
140
+ await writeJson(path.join(root, "apps", appName, "tsconfig.json"), tsconfigJson);
119
141
  await writeJson(path.join(root, "apps", appName, "package.json"), {
120
142
  name: appName,
121
143
  version: "1.0.0",
@@ -125,7 +147,7 @@ export const createTempApp = async (appName = "demo") => {
125
147
  });
126
148
  await writeText(path.join(root, "apps", appName, "akan.config.ts"), "export default {};\n");
127
149
  await mkdir(path.join(root, "apps", appName, "lib", "__scalar"), { recursive: true });
128
- const app = AppExecutor.from(workspace, appName);
150
+ const app = (await executors()).AppExecutor.from(workspace, appName);
129
151
  return { root, workspace, app };
130
152
  };
131
153
 
@@ -138,11 +160,8 @@ export const createTempLib = async (libName = "shared") => {
138
160
  dependencies: {},
139
161
  devDependencies: {},
140
162
  });
141
- await writeJson(path.join(root, "tsconfig.json"), {
142
- compilerOptions: { target: "ESNext", paths: {} },
143
- references: [],
144
- });
145
- const lib = LibExecutor.from(workspace, libName);
163
+ await writeJson(path.join(root, "tsconfig.json"), { ...tsconfigJson, references: [] });
164
+ const lib = (await executors()).LibExecutor.from(workspace, libName);
146
165
  return { root, workspace, lib };
147
166
  };
148
167
 
@@ -155,25 +174,21 @@ export const createTempPackage = async (pkgName = "@sample/tool") => {
155
174
  dependencies: { lodash: "4.0.0" },
156
175
  devDependencies: { typescript: "6.0.0" },
157
176
  });
158
- await writeJson(path.join(root, "tsconfig.json"), {
159
- compilerOptions: { target: "ESNext", paths: {} },
160
- });
177
+ await writeJson(path.join(root, "tsconfig.json"), tsconfigJson);
161
178
  await writeJson(path.join(root, "pkgs", pkgName, "package.json"), {
162
179
  name: pkgName,
163
180
  version: "0.1.0",
164
181
  description: "tool",
165
182
  exports: {},
166
183
  });
167
- await writeJson(path.join(root, "pkgs", pkgName, "tsconfig.json"), {
168
- compilerOptions: { target: "ESNext", paths: {} },
169
- });
184
+ await writeJson(path.join(root, "pkgs", pkgName, "tsconfig.json"), tsconfigJson);
170
185
  await writeText(path.join(root, "pkgs", pkgName, "index.ts"), 'import "lodash";\nexport const value = 1;\n');
171
- const pkg = PkgExecutor.from(workspace, pkgName);
186
+ const pkg = (await executors()).PkgExecutor.from(workspace, pkgName);
172
187
  return { root, workspace, pkg };
173
188
  };
174
189
 
175
190
  export const createTempModule = async (moduleName = "post") => {
176
191
  const { root, workspace, app } = await createTempApp("demo");
177
- const module = ModuleExecutor.from(app, moduleName);
192
+ const module = (await executors()).ModuleExecutor.from(app, moduleName);
178
193
  return { root, workspace, app, module };
179
194
  };
@@ -7,8 +7,7 @@ type TypeScript = typeof ts;
7
7
  export class AsyncDefaultExportDetector {
8
8
  static #typescriptLoad: Promise<TypeScript> | undefined;
9
9
 
10
- // `typescript` costs ~70 MB resident and this detector is reached from the cli entry through the root
11
- // layout generator, so the compiler loads on first use rather than at import (`entryModuleGraph.test.ts`).
10
+ // Lazy: `typescript` is ~70 MB resident and the cli entry reaches this file (`entryModuleGraph.test.ts`).
12
11
  static #loadTypescript(): Promise<TypeScript> {
13
12
  AsyncDefaultExportDetector.#typescriptLoad ??= import("typescript").then(
14
13
  (mod) => (mod.default ?? mod) as TypeScript,
@@ -25,7 +24,7 @@ export class AsyncDefaultExportDetector {
25
24
  source,
26
25
  typescript.ScriptTarget.Latest,
27
26
  true,
28
- AsyncDefaultExportDetector.#scriptKind(typescript, moduleAbsPath),
27
+ /\.[tj]sx$/.test(moduleAbsPath) ? typescript.ScriptKind.TSX : typescript.ScriptKind.TS,
29
28
  );
30
29
  return new AsyncDefaultExportDetector(typescript).detectInSourceFile(sourceFile);
31
30
  } catch {
@@ -72,8 +71,7 @@ export class AsyncDefaultExportDetector {
72
71
  }
73
72
 
74
73
  if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
75
- const exportClause = statement.exportClause;
76
- for (const specifier of exportClause.elements) {
74
+ for (const specifier of statement.exportClause.elements) {
77
75
  if (specifier.name.text !== "default") continue;
78
76
  defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
79
77
  }
@@ -96,10 +94,4 @@ export class AsyncDefaultExportDetector {
96
94
  this.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
97
95
  );
98
96
  }
99
-
100
- static #scriptKind(typescript: TypeScript, moduleAbsPath: string): ts.ScriptKind {
101
- return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx")
102
- ? typescript.ScriptKind.TSX
103
- : typescript.ScriptKind.TS;
104
- }
105
97
  }