@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/fileEditor.ts CHANGED
@@ -17,57 +17,29 @@ export class FileEditor {
17
17
  }
18
18
 
19
19
  find(pattern: string | RegExp): number {
20
- const lines = this.#content.split("\n");
21
20
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
22
-
23
- for (let i = 0; i < lines.length; i++) {
24
- const line = lines[i];
25
- if (!line) continue;
26
- if (regex.test(line)) return i;
27
- }
28
-
29
- return -1;
21
+ return this.#content.split("\n").findIndex((line) => !!line && regex.test(line));
30
22
  }
31
23
 
32
24
  findAll(pattern: string | RegExp): number[] {
33
- const lines = this.#content.split("\n");
34
25
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
35
- const matches: number[] = [];
36
-
37
- for (let i = 0; i < lines.length; i++) {
38
- const line = lines[i];
39
- if (!line) continue;
40
- if (regex.test(line)) matches.push(i);
41
- }
42
-
43
- return matches;
26
+ return this.#content.split("\n").flatMap((line, index) => (line && regex.test(line) ? [index] : []));
44
27
  }
45
28
 
46
29
  insertAfter(pattern: string | RegExp, data: string): this {
47
- const lineIndex = this.find(pattern);
48
-
49
- if (lineIndex === -1) {
50
- throw new Error(`Pattern not found: ${pattern}`);
51
- }
52
-
53
- const lines = this.#content.split("\n");
54
- lines.splice(lineIndex + 1, 0, data);
55
- this.#content = lines.join("\n");
56
-
57
- return this;
30
+ return this.#insertAt(pattern, data, 1);
58
31
  }
59
32
 
60
33
  insertBefore(pattern: string | RegExp, data: string): this {
61
- const lineIndex = this.find(pattern);
62
-
63
- if (lineIndex === -1) {
64
- throw new Error(`Pattern not found: ${pattern}`);
65
- }
34
+ return this.#insertAt(pattern, data, 0);
35
+ }
66
36
 
37
+ #insertAt(pattern: string | RegExp, data: string, offset: number): this {
38
+ const lineIndex = this.find(pattern);
39
+ if (lineIndex === -1) throw new Error(`Pattern not found: ${pattern}`);
67
40
  const lines = this.#content.split("\n");
68
- lines.splice(lineIndex, 0, data);
41
+ lines.splice(lineIndex + offset, 0, data);
69
42
  this.#content = lines.join("\n");
70
-
71
43
  return this;
72
44
  }
73
45
 
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
- import { getDirname } from "./getDirname";
4
+ import { getDirname } from "./fileSys";
5
5
 
6
6
  describe("getDirname", () => {
7
7
  test("converts file URLs to filesystem paths", () => {
package/fileSys.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { lstat, rename, rm, stat } from "node:fs/promises";
2
+ import nodePath from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { Logger } from "akanjs/common";
3
5
 
6
+ export const getDirname = (url: string) => nodePath.dirname(fileURLToPath(url));
7
+
4
8
  export class FileSys {
5
9
  static logger = new Logger("FileSys");
6
10
  static async fileExists(path: string) {
@@ -39,14 +43,8 @@ export class FileSys {
39
43
  static async writeText(path: string, content: string) {
40
44
  return await Bun.file(path).write(content);
41
45
  }
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
- */
46
+ // One `rename`, so a watcher never reads a half-written barrel as a user edit. The temp is a sibling (rename is atomic
47
+ // only within a filesystem), and its `.tmp` suffix is what `HmrChangeClassifier` ignores it by.
50
48
  static async writeTextAtomic(filePath: string, content: string) {
51
49
  const temp = `${filePath}.${process.pid}.${Date.now().toString(36)}.tmp`;
52
50
  try {
@@ -1,22 +1,9 @@
1
1
  import ts from "typescript";
2
2
  import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
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
- */
4
+ // Only a setter passed by reference publishes its field (`data-akan-action`, agent tool). The grit rule flags only pure
5
+ // forwarding wrappers; this counts every field left unpublished whatever the reason. A zero-parameter handler is a
6
+ // button setting a constant, not a form control, so it is not counted.
20
7
  export class FormSetterScanner {
21
8
  static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
22
9
 
@@ -14,11 +14,6 @@ export interface BuildAllRoutesResult {
14
14
  seedIndex: RouteSeedIndex;
15
15
  }
16
16
 
17
- /**
18
- * Walk every route in `pages` and produce its client bundle up-front.
19
- * Intended to run under `akan build` (production) so the serve path
20
- * never needs to compile.
21
- */
22
17
  export class AllRoutesBuilder {
23
18
  #app: App;
24
19
  #artifact: BaseBuildArtifact;
@@ -47,23 +42,16 @@ export class AllRoutesBuilder {
47
42
  this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
48
43
  this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
49
44
 
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>();
45
+ // Every route's entries go into one Bun.build: chunk splitting only dedupes within a single build.
46
+ const allEntries = new Set<string>();
55
47
  for (const entry of seedIndex.entries) {
56
48
  const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
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
- }
49
+ for (const discovered of await this.#discovery.discover(seeds)) allEntries.add(discovered);
62
50
  this.#routeIds.push(entry.routeId);
63
51
  }
64
- this.#app.verbose(`[build-all] ${allEntries.length} client entries across ${this.#routeIds.length} routes`);
52
+ this.#app.verbose(`[build-all] ${allEntries.size} client entries across ${this.#routeIds.length} routes`);
65
53
 
66
- const delta = await this.#buildEntries(allEntries);
54
+ const delta = await this.#buildEntries([...allEntries]);
67
55
  this.#mergeDelta(delta);
68
56
  this.#merged.knownEntries = Array.from(this.#knownSet);
69
57
 
@@ -113,10 +101,8 @@ export class AllRoutesBuilder {
113
101
  }
114
102
 
115
103
  #mergeDelta(delta: BuildRouteClientResult): void {
116
- for (const [key, row] of Object.entries(delta.manifestDelta)) this.#merged.clientManifest[key] = row;
117
- for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap)) {
118
- this.#merged.ssrManifest.moduleMap[url] = byName;
119
- }
104
+ Object.assign(this.#merged.clientManifest, delta.manifestDelta);
105
+ Object.assign(this.#merged.ssrManifest.moduleMap, delta.ssrManifestDelta.moduleMap);
120
106
  for (const abs of delta.newEntries) this.#knownSet.add(abs);
121
107
  }
122
108
  }
@@ -1,21 +1,18 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
5
5
  import { AutoImportSync, transformSource } from "./autoImportSync";
6
6
 
7
- const tempRoots: string[] = [];
7
+ const makeTempRoot = tempDirs("akan-auto-import-");
8
8
 
9
- const makeTempRoot = async () => {
10
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-auto-import-"));
11
- tempRoots.push(root);
12
- return root;
9
+ const seed = async (root: string, rel: string, content: string) => {
10
+ const abs = path.join(root, rel);
11
+ await mkdir(path.dirname(abs), { recursive: true });
12
+ await writeFile(abs, content);
13
+ return abs;
13
14
  };
14
15
 
15
- afterEach(async () => {
16
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
17
- });
18
-
19
16
  const constantCtx = { role: "constant", scope: "libs", project: "shared" } as const;
20
17
  const documentCtx = { role: "document", scope: "libs", project: "shared" } as const;
21
18
  const serviceCtx = { role: "service", scope: "libs", project: "shared" } as const;
@@ -184,13 +181,6 @@ describe("transformSource — client role (package client entry)", () => {
184
181
  });
185
182
 
186
183
  describe("AutoImportSync.syncForBatch", () => {
187
- const seed = async (root: string, rel: string, content: string) => {
188
- const abs = path.join(root, rel);
189
- await mkdir(path.dirname(abs), { recursive: true });
190
- await writeFile(abs, content);
191
- return abs;
192
- };
193
-
194
184
  test("writes the fix once, then is idempotent", async () => {
195
185
  const root = await makeTempRoot();
196
186
  const abs = await seed(
@@ -309,9 +299,7 @@ describe("AutoImportSync.syncForBatch", () => {
309
299
 
310
300
  test("leaves JS globals and ambiguous domain names alone", async () => {
311
301
  const root = await makeTempRoot();
312
- // `Map` is a real model here but must not shadow the JS global.
313
302
  await seed(root, "libs/x/lib/map/map.constant.ts", `export class Map extends via {}\n`);
314
- // `Dup` is exported by two files → ambiguous → skipped.
315
303
  await seed(root, "libs/x/lib/a/a.constant.ts", `export class Dup {}\n`);
316
304
  await seed(root, "libs/x/lib/b/b.constant.ts", `export class Dup {}\n`);
317
305
  const consumer = await seed(
@@ -348,7 +336,7 @@ describe("AutoImportSync.syncForBatch", () => {
348
336
 
349
337
  test("srvkit: does not import a barrel whose file is absent", async () => {
350
338
  const root = await makeTempRoot();
351
- await seed(root, "libs/x/lib/dict.ts", `export const x = 1;\n`); // no lib/srv.ts
339
+ await seed(root, "libs/x/lib/dict.ts", `export const x = 1;\n`);
352
340
  const file = await seed(root, "libs/x/srvkit/api.ts", `export const f = () => Err.of(srv.user);\n`);
353
341
  const sync = new AutoImportSync({ workspaceRoot: root });
354
342
 
@@ -370,20 +358,11 @@ describe("AutoImportSync.syncForBatch", () => {
370
358
  });
371
359
 
372
360
  describe("AutoImportSync — scope", () => {
373
- const writeAt = async (root: string, rel: string, source: string) => {
374
- const abs = path.join(root, rel);
375
- await mkdir(path.dirname(abs), { recursive: true });
376
- await writeFile(abs, source);
377
- return abs;
378
- };
379
-
380
- // `lib/__lib/` is generated and gitignored: `akan sync` rewrites it, which is also what makes the
381
- // watcher hand it to the syncer, so an edit there is churn that the next sync discards.
382
361
  test("leaves the generated lib/__lib stubs alone and still syncs lib/__scalar", async () => {
383
362
  const root = await makeTempRoot();
384
363
  const needsImport = "export class A {\n count = field(Int);\n}\n";
385
- const generated = await writeAt(root, "libs/shared/lib/__lib/lib.constant.ts", needsImport);
386
- const scalar = await writeAt(root, "libs/shared/lib/__scalar/money/money.constant.ts", needsImport);
364
+ const generated = await seed(root, "libs/shared/lib/__lib/lib.constant.ts", needsImport);
365
+ const scalar = await seed(root, "libs/shared/lib/__scalar/money/money.constant.ts", needsImport);
387
366
 
388
367
  const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([generated, scalar]);
389
368
 
@@ -393,11 +372,9 @@ describe("AutoImportSync — scope", () => {
393
372
  expect(await readFile(scalar, "utf8")).toContain('import { Int } from "akanjs/base";');
394
373
  });
395
374
 
396
- // The transform is pure and the write is conditional on a difference, so a second pass over its own
397
- // output writes nothing — without that the watcher would re-fire on the syncer's own edit.
398
375
  test("a second pass over its own output changes nothing", async () => {
399
376
  const root = await makeTempRoot();
400
- const file = await writeAt(
377
+ const file = await seed(
401
378
  root,
402
379
  "libs/shared/lib/task/task.constant.ts",
403
380
  "export class A {\n count = field(Int);\n}\n",
@@ -410,11 +387,10 @@ describe("AutoImportSync — scope", () => {
410
387
  expect(await readFile(file, "utf8")).toBe(afterFirst);
411
388
  });
412
389
 
413
- // One try/catch per file: a file that cannot be read is reported and the rest of the batch still runs.
414
390
  test("a failing file is reported without abandoning the batch", async () => {
415
391
  const root = await makeTempRoot();
416
392
  const missing = path.join(root, "libs/shared/lib/task/gone.constant.ts");
417
- const good = await writeAt(
393
+ const good = await seed(
418
394
  root,
419
395
  "libs/shared/lib/task/task.constant.ts",
420
396
  "export class A {\n count = field(Int);\n}\n",
@@ -3,11 +3,7 @@ import path from "node:path";
3
3
  import ts from "typescript";
4
4
  import { FileSys } from "../fileSys";
5
5
 
6
- //* Auto-import daemon: a source-editing sibling of DevGeneratedIndexSync. When a domain file changes,
7
- //* framework symbols that are used but not imported (e.g. `Int` in a *.constant.ts, `fetch` in a *.store.ts)
8
- //* are inserted automatically. Detection is a closed-registry scan: we only look for a fixed, framework-owned
9
- //* set of identifiers per file role, so no type information is required. Writes only happen on a real diff,
10
- //* which keeps the watcher from looping on its own edits.
6
+ //* Closed-registry scan (no type information); writes only on a real diff, or the watcher loops on its own edits.
11
7
 
12
8
  export interface AutoImportSyncResult {
13
9
  changedFiles: string[];
@@ -39,13 +35,9 @@ interface FileContext {
39
35
  interface ImportTarget {
40
36
  specifier: string;
41
37
  kind: ImportKind;
42
- //* When true, emitted as a type-only import (`import type { X }`, or inline `type X` when mixed with
43
- //* value names from the same specifier). Only meaningful for `kind: "named"`.
44
38
  typeOnly?: boolean;
45
39
  }
46
40
 
47
- //* A registry rule: the identifiers `names` should be imported from `specifier` with the given `kind`.
48
- //* `specifier` may depend on the file's package (e.g. the client entrypoint).
49
41
  interface ImportRule {
50
42
  names: string[];
51
43
  specifier: string | ((ctx: FileContext) => string);
@@ -55,23 +47,19 @@ interface ImportRule {
55
47
 
56
48
  const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx)$/;
57
49
 
58
- //* Shared rules reused across roles. Domain (`lib/<model>/`) files sit at a fixed depth, so the
59
- //* relative barrels `../cnst`, `../db`, `../srv`, `../dict` are always correct for them.
50
+ //* Domain files sit at a fixed depth (`lib/<model>/`), so the relative barrels below are always correct for them.
60
51
  const AKAN_BASE: ImportRule = {
61
52
  names: ["Int", "Float", "ID", "Any", "Upload", "enumOf", "dayjs"],
62
53
  specifier: "akanjs/base",
63
54
  kind: "named",
64
55
  };
65
- //* Type-only exports of akanjs/base — emitted as `type` imports (e.g. `import { type Dayjs, dayjs }`).
66
56
  const AKAN_BASE_TYPES: ImportRule = { names: ["Dayjs"], specifier: "akanjs/base", kind: "named", typeOnly: true };
67
57
  const CNST_NS: ImportRule = { names: ["cnst"], specifier: "../cnst", kind: "namespace" };
68
58
  const DB_NS: ImportRule = { names: ["db"], specifier: "../db", kind: "namespace" };
69
59
  const SRV_NS: ImportRule = { names: ["srv"], specifier: "../srv", kind: "namespace" };
70
60
  const ERR_DICT: ImportRule = { names: ["Err"], specifier: "../dict", kind: "named" };
71
61
 
72
- //* The closed registry, keyed by file role. Derived from an import-frequency scan across apps/libs;
73
- //* only high-confidence, framework-owned identifiers are listed (domain model/scalar refs are resolved
74
- //* separately). Order matters only when the same name could appear twice — keep names unique per role.
62
+ //* Keep names unique per role: targetFor takes the first rule that lists a name.
75
63
  const RULES: Record<FileRole, ImportRule[]> = {
76
64
  constant: [AKAN_BASE, AKAN_BASE_TYPES, { names: ["via"], specifier: "akanjs/constant", kind: "named" }],
77
65
  document: [
@@ -143,8 +131,6 @@ const RULES: Record<FileRole, ImportRule[]> = {
143
131
  ],
144
132
  };
145
133
 
146
- //* Domain imports resolve open-ended model/scalar class refs against a per-package index, rather than
147
- //* a fixed registry. Only these roles opt in, and each may pull from the listed sibling file kinds.
148
134
  type DomainKind = "constant" | "document" | "signal";
149
135
  type DomainIndex = Map<string, { file: string; kind: DomainKind }[]>;
150
136
  const PASCAL_CASE_RE = /^[A-Z][A-Za-z0-9]*$/;
@@ -153,21 +139,17 @@ const DOMAIN_ROLE_KINDS: Partial<Record<FileRole, DomainKind[]>> = {
153
139
  dictionary: ["constant", "document", "signal"],
154
140
  common: ["constant"],
155
141
  };
156
- //* lib barrel imports (srvkit/common): identifier -> generated `lib/<barrel>.ts` file + import shape.
157
- //* Their specifier depth is per-file (see `#libBarrelResolver`), so they can't live in the RULES table.
142
+ //* srvkit/common import the lib barrels from a per-file depth, so they cannot live in the RULES table.
158
143
  const LIB_BARRELS: Record<string, { barrel: string; kind: ImportKind }> = {
159
144
  Err: { barrel: "dict", kind: "named" },
160
145
  db: { barrel: "db", kind: "namespace" },
161
146
  cnst: { barrel: "cnst", kind: "namespace" },
162
147
  srv: { barrel: "srv", kind: "namespace" },
163
148
  };
164
- //* Registry names that are also runtime globals. An unbound `fetch(url)` is the platform's fetch, and importing Akan's
165
- //* over it silently rebinds the call, so such a name is claimed only when every reference has the Akan member shape
166
- //* (`fetch.viewX()`); one bare use leaves the file alone.
149
+ //* Importing Akan's `fetch` over the platform global silently rebinds `fetch(url)`, so such a name is claimed
150
+ //* only when every reference is member-shaped (`fetch.viewX()`).
167
151
  const GLOBAL_NAMES = new Set(["fetch"]);
168
- //* ECMAScript/TS structural globals never resolved as domain symbols, so that a package model named
169
- //* e.g. `Map` cannot shadow the JS `Map` used in `new Map()`. Domain-y globals like `File` are allowed
170
- //* on purpose (they are real models here).
152
+ //* Structural globals a package model must not shadow (`new Map()`); `File` is left out on purpose — it is a real model.
171
153
  const DOMAIN_DENYLIST = new Set([
172
154
  "Map",
173
155
  "Set",
@@ -208,7 +190,6 @@ const DOMAIN_DENYLIST = new Set([
208
190
 
209
191
  export class AutoImportSync {
210
192
  readonly #workspaceRoot: string;
211
- //* Per-package domain index (symbol -> source file), invalidated when a package's model file changes.
212
193
  readonly #domainCache = new Map<string, DomainIndex>();
213
194
 
214
195
  constructor({ workspaceRoot }: AutoImportSyncOptions) {
@@ -220,8 +201,6 @@ export class AutoImportSync {
220
201
  const errors: string[] = [];
221
202
  const seen = new Set<string>();
222
203
 
223
- // Drop cached domain indexes for any package whose model files changed in this batch, so a newly
224
- // added/renamed model is visible to references resolved later in the same batch.
225
204
  for (const file of files) {
226
205
  const pkgRoot = this.#domainPkgRootOf(file);
227
206
  if (pkgRoot) this.#domainCache.delete(pkgRoot);
@@ -244,8 +223,6 @@ export class AutoImportSync {
244
223
  return { changedFiles, errors };
245
224
  }
246
225
 
247
- //* Resolve the role + package location for a file, or null when the file is out of scope
248
- //* (outside apps/libs, a generated/declaration/test file, or a facet+extension we do not handle).
249
226
  #contextFor(abs: string): FileContext | null {
250
227
  const rel = path.relative(this.#workspaceRoot, abs);
251
228
  if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
@@ -255,9 +232,7 @@ export class AutoImportSync {
255
232
  const parts = rel.split(path.sep).filter(Boolean);
256
233
  const [scope, project, facet] = parts;
257
234
  if ((scope !== "apps" && scope !== "libs") || !project || !facet) return null;
258
- //* `lib/__lib/` holds the generated per-lib re-export stubs — gitignored, and rewritten by every
259
- //* `akan sync`, which is also what makes the watcher report them. An edit there is churn at best and
260
- //* silently discarded at worst. `lib/__scalar/` is real source and stays in scope.
235
+ //* `lib/__lib/` is rewritten by every `akan sync`, so an edit there is discarded; `lib/__scalar/` is real source.
261
236
  if (facet === "lib" && parts[3] === "__lib") return null;
262
237
  const role = roleFor(facet, base);
263
238
  if (!role) return null;
@@ -276,10 +251,6 @@ export class AutoImportSync {
276
251
  return true;
277
252
  }
278
253
 
279
- //* Build the dynamic resolver for identifiers the static registry does not cover, or undefined when
280
- //* the role has none. `srvkit`/`common` resolve the package's generated lib barrels (variable depth);
281
- //* the domain roles resolve open-ended model/scalar refs against the per-package index. `common`
282
- //* uses both — barrels first, then domain (the two symbol sets are disjoint).
283
254
  async #extraResolverFor(abs: string, ctx: FileContext) {
284
255
  const resolvers: ((symbol: string) => ImportTarget | null)[] = [];
285
256
  if (ctx.role === "srvkit" || ctx.role === "common") resolvers.push(await this.#libBarrelResolver(abs, ctx));
@@ -295,22 +266,17 @@ export class AutoImportSync {
295
266
  };
296
267
  }
297
268
 
298
- //* Resolve an unbound PascalCase identifier to a relative import of the package model/scalar that
299
- //* exports it (only when exactly one package file of an allowed kind exports that name).
300
269
  async #domainResolver(abs: string, ctx: FileContext, kinds: DomainKind[]) {
301
270
  const index = await this.#domainIndex(path.join(this.#workspaceRoot, ctx.scope, ctx.project));
302
271
  const fileDir = path.dirname(abs);
303
272
  return (symbol: string): ImportTarget | null => {
304
273
  if (!PASCAL_CASE_RE.test(symbol) || DOMAIN_DENYLIST.has(symbol)) return null;
305
274
  const entries = (index.get(symbol) ?? []).filter((entry) => kinds.includes(entry.kind));
306
- if (entries.length !== 1) return null; // unknown or ambiguous → leave it alone
275
+ if (entries.length !== 1) return null;
307
276
  return { specifier: relativeSpecifier(fileDir, entries[0].file), kind: "named" };
308
277
  };
309
278
  }
310
279
 
311
- //* srvkit/common files import the package's generated lib barrels from a variable depth
312
- //* (`../lib/dict`, `../../lib/dict`, …). Resolve each barrel name to a path relative to this file,
313
- //* but only for barrels that actually exist (so an absent `lib/srv.ts` never yields a broken import).
314
280
  async #libBarrelResolver(abs: string, ctx: FileContext) {
315
281
  const fileDir = path.dirname(abs);
316
282
  const libDir = path.join(this.#workspaceRoot, ctx.scope, ctx.project, "lib");
@@ -330,7 +296,6 @@ export class AutoImportSync {
330
296
  return index;
331
297
  }
332
298
 
333
- //* The apps/libs package root for a model file (`*.constant/document/signal.ts` under `lib/`), else null.
334
299
  #domainPkgRootOf(file: string): string | null {
335
300
  const rel = path.relative(this.#workspaceRoot, path.resolve(file));
336
301
  if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
@@ -341,9 +306,6 @@ export class AutoImportSync {
341
306
  }
342
307
  }
343
308
 
344
- //* Which facet+extension combinations get auto-imports, and the role that drives their registry.
345
- //* `client` covers every file that pulls domain symbols from the package client entrypoint:
346
- //* lib UI (.tsx), the `ui`/`page` facets (.tsx), and the `webkit` facet (.ts/.tsx).
347
309
  const roleFor = (facet: string, base: string): FileRole | null => {
348
310
  if (facet === "lib") {
349
311
  if (base.endsWith(".constant.ts")) return "constant";
@@ -371,8 +333,7 @@ const targetFor = (symbol: string, ctx: FileContext): ImportTarget | null => {
371
333
  return null;
372
334
  };
373
335
 
374
- //* Returns the rewritten source, or null when nothing needs to change. `resolveExtra` (when supplied)
375
- //* handles identifiers not matched by the framework registry — domain model refs and srvkit barrels.
336
+ /** The rewritten source, or null when nothing changes. */
376
337
  export const transformSource = (
377
338
  source: string,
378
339
  fileName: string,
@@ -384,8 +345,6 @@ export const transformSource = (
384
345
  const used = collectUsedReferences(sf);
385
346
  const bareGlobals = collectBareGlobalReferences(sf);
386
347
 
387
- // Group the needed symbols by their import target: framework registry first, then dynamic resolver.
388
- // Named groups map each name to whether it is type-only, so we can emit `import type`/inline `type`.
389
348
  const namedBySpecifier = new Map<string, Map<string, boolean>>();
390
349
  const namespaceImports: { name: string; specifier: string }[] = [];
391
350
  for (const name of used) {
@@ -423,8 +382,7 @@ export const transformSource = (
423
382
  for (const ns of namespaceImports) newStatements.push(`import * as ${ns.name} from "${ns.specifier}";`);
424
383
 
425
384
  if (newStatements.length > 0) {
426
- // If the anchor import is itself being merged, fold the new lines into that edit to avoid a
427
- // zero-width insertion sharing a boundary with the merge replacement.
385
+ // A zero-width insertion at the anchor's boundary would collide with the anchor's own merge edit.
428
386
  const anchorEdit = anchor ? edits.find((edit) => edit.start === anchor.getStart(sf)) : undefined;
429
387
  if (anchorEdit) anchorEdit.text = `${anchorEdit.text}\n${newStatements.join("\n")}`;
430
388
  else if (anchor)
@@ -443,8 +401,7 @@ export const transformSource = (
443
401
  return out === source ? null : out;
444
402
  };
445
403
 
446
- //* Every name introduced into scope: import bindings plus local declarations. Over-collecting only
447
- //* suppresses an insertion (safe); under-collecting would risk a duplicate import (unsafe).
404
+ //* Over-collecting only suppresses an insertion; under-collecting would add a duplicate import.
448
405
  const collectBoundNames = (sf: ts.SourceFile): Set<string> => {
449
406
  const names = new Set<string>();
450
407
  const visit = (node: ts.Node) => {
@@ -496,8 +453,6 @@ const collectBareGlobalReferences = (sf: ts.SourceFile): Set<string> => {
496
453
  return bare;
497
454
  };
498
455
 
499
- //* True when the identifier reads a binding, as opposed to being a member name, property key, or
500
- //* declaration name. e.g. in `cnst.Coordinate`, `cnst` is a reference but `Coordinate` is not.
501
456
  const isReferencePosition = (node: ts.Identifier): boolean => {
502
457
  const parent = node.parent;
503
458
  if (!parent) return true;
@@ -512,7 +467,7 @@ const isReferencePosition = (node: ts.Identifier): boolean => {
512
467
 
513
468
  interface ExistingNamedImport {
514
469
  decl: ts.ImportDeclaration;
515
- names: Map<string, boolean>; // name -> type-only (whole-clause `import type` or inline `type`)
470
+ names: Map<string, boolean>;
516
471
  }
517
472
 
518
473
  const collectExistingNamedImports = (importDecls: ts.ImportDeclaration[]): Map<string, ExistingNamedImport> => {
@@ -523,7 +478,7 @@ const collectExistingNamedImports = (importDecls: ts.ImportDeclaration[]): Map<s
523
478
  if (!clause || !bindings || !ts.isNamedImports(bindings)) continue;
524
479
  if (!ts.isStringLiteral(decl.moduleSpecifier)) continue;
525
480
  const specifier = decl.moduleSpecifier.text;
526
- if (map.has(specifier)) continue; // first same-specifier named import wins as the merge site
481
+ if (map.has(specifier)) continue;
527
482
  const names = new Map<string, boolean>();
528
483
  for (const el of bindings.elements) names.set(el.name.text, clause.isTypeOnly || el.isTypeOnly);
529
484
  map.set(specifier, { decl, names });
@@ -531,8 +486,6 @@ const collectExistingNamedImports = (importDecls: ts.ImportDeclaration[]): Map<s
531
486
  return map;
532
487
  };
533
488
 
534
- //* Render a named import, hoisting to `import type { … }` when every name is type-only and otherwise
535
- //* prefixing each type-only name with inline `type`. Names sorted case-insensitively (Biome order).
536
489
  const formatNamedImport = (specifier: string, names: Map<string, boolean>): string => {
537
490
  const entries = [...names.entries()].sort((a, b) => compareNames(a[0], b[0]));
538
491
  if (entries.every(([, isType]) => isType))
@@ -552,8 +505,7 @@ const directivePrologueEnd = (sf: ts.SourceFile): number => {
552
505
 
553
506
  const scriptKindFor = (fileName: string) => (fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
554
507
 
555
- //* Case-insensitive primary order with a case-sensitive tie-break (uppercase first), matching Biome's
556
- //* import specifier sort — so e.g. `Dayjs` precedes `dayjs`.
508
+ // Matches Biome's import-specifier sort: case-insensitive, uppercase first on a tie (`Dayjs` before `dayjs`).
557
509
  const compareNames = (a: string, b: string): number => {
558
510
  const [la, lb] = [a.toLowerCase(), b.toLowerCase()];
559
511
  if (la !== lb) return la < lb ? -1 : 1;
@@ -567,8 +519,6 @@ const fileExists = async (file: string) =>
567
519
  .then((s) => s.isFile())
568
520
  .catch(() => false);
569
521
 
570
- // ---- Domain index ---------------------------------------------------------------------------------
571
-
572
522
  const domainKindOf = (base: string): DomainKind | null => {
573
523
  if (base.endsWith(".constant.ts")) return "constant";
574
524
  if (base.endsWith(".document.ts")) return "document";
@@ -576,7 +526,6 @@ const domainKindOf = (base: string): DomainKind | null => {
576
526
  return null;
577
527
  };
578
528
 
579
- //* Scan a package `lib/` for model files and index their exported PascalCase declarations.
580
529
  const buildDomainIndex = async (libDir: string): Promise<DomainIndex> => {
581
530
  const index: DomainIndex = new Map();
582
531
  for (const file of await collectDomainFiles(libDir)) {
@@ -622,7 +571,6 @@ const exportedPascalNames = (source: string, fileName: string): string[] => {
622
571
  return names.filter((name) => PASCAL_CASE_RE.test(name));
623
572
  };
624
573
 
625
- //* Relative module specifier from a file's directory to a target file, extension stripped, `./`-anchored.
626
574
  const relativeSpecifier = (fromDir: string, toFile: string): string => {
627
575
  const rel = path
628
576
  .relative(fromDir, toFile)
@@ -1,29 +1,13 @@
1
- import { afterEach, 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";
4
2
  import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
5
4
  import { createTsconfigPackageResolver } from "../transforms/barrelImportsPlugin";
6
5
  import { CLIENT_BUNDLE_NAMING } from "./clientBuildTypes";
7
6
  import { ClientEntriesBundler } from "./clientEntriesBundler";
8
7
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
9
8
  import { RouteClientBuilder } from "./routeClientBuilder";
10
9
 
11
- const tempRoots: string[] = [];
12
-
13
- const makeTempRoot = async () => {
14
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-client-entry-"));
15
- tempRoots.push(root);
16
- return root;
17
- };
18
-
19
- const write = async (filePath: string, content: string) => {
20
- await mkdir(path.dirname(filePath), { recursive: true });
21
- await writeFile(filePath, content);
22
- };
23
-
24
- afterEach(async () => {
25
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
26
- });
10
+ const makeTempRoot = tempDirs("akan-client-entry-");
27
11
 
28
12
  describe("route client store bootstrap", () => {
29
13
  test("wraps client entries with app client bootstrap before re-exporting components", () => {
@@ -210,24 +194,14 @@ describe("route client store bootstrap", () => {
210
194
  await write(uiEntry, 'export { ClientPathWrapper } from "./System/Client";\n');
211
195
  await write(clientEntry, '"use client";\nexport const ClientPathWrapper = () => null;\n');
212
196
 
197
+ const entryFiles = new Map([
198
+ ["akanjs/ui", uiEntry],
199
+ ["akanjs/ui/System/Client.tsx", clientEntry],
200
+ ]);
213
201
  const discovery = new GraphClientEntryDiscovery({ barrelImports: ["akanjs/ui"] }, async (specifier) => {
214
- if (specifier === "akanjs/ui") {
215
- return {
216
- pkgName: "akanjs/ui",
217
- entryFile: uiEntry,
218
- pkgDir: path.dirname(uiEntry),
219
- preserveFilePath: true,
220
- };
221
- }
222
- if (specifier === "akanjs/ui/System/Client.tsx") {
223
- return {
224
- pkgName: "akanjs/ui/System/Client.tsx",
225
- entryFile: clientEntry,
226
- pkgDir: path.dirname(clientEntry),
227
- preserveFilePath: true,
228
- };
229
- }
230
- return null;
202
+ const entryFile = entryFiles.get(specifier);
203
+ if (!entryFile) return null;
204
+ return { pkgName: specifier, entryFile, pkgDir: path.dirname(entryFile), preserveFilePath: true };
231
205
  });
232
206
 
233
207
  expect(await discovery.discover([seed])).toEqual([clientEntry]);