@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
@@ -21,10 +21,6 @@ export interface OptimizeAppFontsResult {
21
21
  files: string[];
22
22
  }
23
23
 
24
- /**
25
- * Lets a boot that changed nothing about its fonts skip re-subsetting them. `files` is stored relative
26
- * to the artifact root so the `build` and `start` roots keep independent, relocatable caches.
27
- */
28
24
  interface FontOptimizerCache {
29
25
  version: number;
30
26
  key: string;
@@ -43,8 +39,6 @@ export class FontOptimizer {
43
39
  #woff2Ready: Promise<void> | null = null;
44
40
 
45
41
  static #ksX1001Text: string | null = null;
46
- // 2: the key moved to sha256 over a deterministically ordered `auto` text, so v1 entries cannot be
47
- // compared against and their subsets were built from `page`/`ui` only.
48
42
  static readonly #cacheVersion = 2;
49
43
 
50
44
  constructor(app: App, command: FontOptimizerCommand = "start") {
@@ -76,10 +70,7 @@ export class FontOptimizer {
76
70
  return path.join(this.#artifactRoot, "fontCache.json");
77
71
  }
78
72
 
79
- /**
80
- * Null means "do not cache this run": a source we cannot stat is a source whose staleness we cannot
81
- * detect, and the uncached path is also the one that warns about it.
82
- */
73
+ // null skips the cache: an unstat-able source's staleness is undetectable, and the uncached path warns about it.
83
74
  async #buildCacheKey(fonts: ReactFont[]): Promise<string | null> {
84
75
  const sources: unknown[] = [];
85
76
  for (const font of fonts) {
@@ -96,18 +87,13 @@ export class FontOptimizer {
96
87
  if (!stamp) return null;
97
88
  sources.push({ subsetFile: filePath, ...stamp });
98
89
  }
99
- // `auto` derives the subset from app source text, which no font config hash can capture.
100
90
  if (this.#getFontSubsets(font).includes("auto"))
101
91
  sources.push({ autoSubsetText: this.#cacheDigest(await this.#collectAutoSubsetText()) });
102
92
  }
103
93
  return this.#cacheDigest({ version: FontOptimizer.#cacheVersion, fonts, sources });
104
94
  }
105
95
 
106
- /**
107
- * Cache keys get a cryptographic digest, not the 32-bit FNV `#hashFontConfig` computes for filenames:
108
- * a collision there serves a stale subset as if it were current, while a filename only has to be short
109
- * and stable.
110
- */
96
+ // sha256, not #hashFontConfig's 32-bit FNV: a key collision would serve a stale subset as current.
111
97
  #cacheDigest(value: unknown) {
112
98
  return new Bun.CryptoHasher("sha256").update(this.#stableStringify(value)).digest("hex");
113
99
  }
@@ -152,13 +138,11 @@ export class FontOptimizer {
152
138
  const file = Bun.file(filePath);
153
139
  if (!(await file.exists())) return;
154
140
  const source = await file.text();
155
- // A `.fonts()` stage or a `fonts` declaration cannot exist in text that never mentions it, and parsing the
156
- // route files that never declare one is what a cached optimize() otherwise spends its time on.
157
141
  if (!source.includes("fonts")) return;
158
142
  fonts.push(...this.#extractFontsExport(source, filePath));
159
143
  }),
160
144
  );
161
- return this.#dedupeFonts(fonts);
145
+ return [...new Map(fonts.map((font) => [JSON.stringify(font), font] as const)).values()];
162
146
  }
163
147
 
164
148
  async #optimizeFont(font: ReactFont) {
@@ -186,12 +170,6 @@ export class FontOptimizer {
186
170
  if (faceCss.length > 0) this.#cssParts.push(...faceCss, this.#buildRootVariableRule(font));
187
171
  }
188
172
 
189
- /**
190
- * The font list of a route file, from the `.fonts([…])` stage of its default-exported `rootLayout()` chain
191
- * or from the legacy `export const fonts`. Only an inline literal is readable, because the build enumerates
192
- * routes without evaluating them — and a list it cannot read is warned about rather than skipped in silence:
193
- * the runtime still emits `/_akan/fonts` preloads for fonts nothing subset, and every one of them 404s.
194
- */
195
173
  #extractFontsExport(source: string, filePath: string): ReactFont[] {
196
174
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
197
175
  const declaration = this.#findFontsDeclaration(sourceFile);
@@ -203,7 +181,7 @@ export class FontOptimizer {
203
181
  this.#app.logger.warn(
204
182
  `[font] ${path.relative(this.#app.cwdPath, filePath)} declares fonts the build cannot read without evaluating the module — write the list inline, or nothing is subset for it and every /_akan/fonts request 404s`,
205
183
  );
206
- return fonts.map((font) => this.#withFontDefaults(font));
184
+ return fonts.map((font) => ({ ...font, subsets: font.subsets ?? [...DEFAULT_FONT_SUBSETS] }));
207
185
  }
208
186
 
209
187
  #isReadableFont(value: unknown): value is ReactFont {
@@ -231,7 +209,7 @@ export class FontOptimizer {
231
209
  return null;
232
210
  }
233
211
 
234
- /** Walks a `rootLayout()` chain from its outermost call inward, so the stage that wins at runtime is the one read. */
212
+ // Outermost call first, so `??=` keeps the stage that wins at runtime.
235
213
  #findChainStageArgument(expression: ts.Expression, stageName: string): ts.Expression | null {
236
214
  let node = this.#unwrapExpression(expression);
237
215
  let argument: ts.Expression | null = null;
@@ -284,16 +262,6 @@ export class FontOptimizer {
284
262
  return null;
285
263
  }
286
264
 
287
- #dedupeFonts(fonts: ReactFont[]) {
288
- const map = new Map<string, ReactFont>();
289
- for (const font of fonts) map.set(JSON.stringify(font), font);
290
- return [...map.values()];
291
- }
292
-
293
- #withFontDefaults(font: ReactFont): ReactFont {
294
- return { ...font, subsets: font.subsets ?? [...DEFAULT_FONT_SUBSETS] };
295
- }
296
-
297
265
  #getFontSubsets(font: ReactFont): ReactFontSubset[] {
298
266
  return font.subsets ?? DEFAULT_FONT_SUBSETS;
299
267
  }
@@ -310,12 +278,8 @@ export class FontOptimizer {
310
278
  return font.optimize !== false;
311
279
  }
312
280
 
313
- #getFontStyles(font: ReactFont): ReactFontStyle[] {
314
- return font.styles?.length ? font.styles : ["normal"];
315
- }
316
-
317
281
  #getFontFaces(font: ReactFont): ReactFontFace[] {
318
- const enabledStyles = new Set(this.#getFontStyles(font));
282
+ const enabledStyles = new Set<ReactFontStyle>(font.styles?.length ? font.styles : ["normal"]);
319
283
  return font.paths
320
284
  .map((fontPath) => {
321
285
  const style = fontPath.style ?? "normal";
@@ -393,8 +357,7 @@ export class FontOptimizer {
393
357
  return null;
394
358
  }
395
359
 
396
- /** `subset-font`, `fonteditor-core` and `fontaine` are imported here rather than at module scope so a
397
- * cache hit — the common case once the cache exists — loads none of them. */
360
+ // The font libraries are imported lazily so a cache hit loads none of them.
398
361
  async #buildFontBuffer(font: ReactFont, sourceBuffer: Buffer, sourcePath: string) {
399
362
  if (font.subset === false) return this.#convertToWoff2(sourceBuffer, sourcePath);
400
363
  const { default: subsetFont } = await import("subset-font");
@@ -403,16 +366,12 @@ export class FontOptimizer {
403
366
 
404
367
  async #convertToWoff2(buffer: Buffer, sourcePath: string) {
405
368
  const { createFont } = await import("fonteditor-core");
406
- await this.#initWoff2();
369
+ this.#woff2Ready ??= import("fonteditor-core").then(({ woff2 }) => woff2.init()).then(() => undefined);
370
+ await this.#woff2Ready;
407
371
  const font = createFont(buffer, { type: this.#getFontType(sourcePath, buffer) });
408
372
  return font.write({ type: "woff2", toBuffer: true });
409
373
  }
410
374
 
411
- async #initWoff2() {
412
- this.#woff2Ready ??= import("fonteditor-core").then(({ woff2 }) => woff2.init()).then(() => undefined);
413
- return this.#woff2Ready;
414
- }
415
-
416
375
  #getFontType(sourcePath: string, buffer: Buffer) {
417
376
  const signature = buffer.toString("ascii", 0, 4);
418
377
  if (signature === "wOFF") return "woff";
@@ -443,25 +402,15 @@ export class FontOptimizer {
443
402
  }
444
403
 
445
404
  async #getSubsetPresetText(subset: ReactFontSubset) {
446
- if (subset === "latin") return this.#rangeText(0x20, 0x7e);
447
- if (subset === "latin-ext") return `${this.#rangeText(0x20, 0x7e)}${this.#rangeText(0xa0, 0x024f)}`;
405
+ if (subset === "latin") return FontOptimizer.#rangeText(0x20, 0x7e);
406
+ if (subset === "latin-ext")
407
+ return `${FontOptimizer.#rangeText(0x20, 0x7e)}${FontOptimizer.#rangeText(0xa0, 0x024f)}`;
448
408
  if (subset === "ks-x-1001") return FontOptimizer.#getKsX1001Text();
449
409
  if (subset === "auto") return this.#collectAutoSubsetText();
450
410
  return "";
451
411
  }
452
412
 
453
- /**
454
- * Every source that can put a glyph on screen, concatenated in a **stable** order.
455
- *
456
- * The order is load-bearing even though a glyph set is not: `#buildCacheKey` hashes this string, so
457
- * reading the roots concurrently and pushing as each file resolved made the key depend on i/o
458
- * scheduling — measured 8 distinct keys over 8 runs against unchanged sources, which means the cache
459
- * never hit and every build re-subset the fonts.
460
- *
461
- * `lib` is in the roots because that is where user-facing text actually lives: a dictionary's
462
- * `[en, ko]` pairs are the Korean in the app, and a subset built from `page` and `ui` alone renders
463
- * them as tofu — while hashing the same partial text also stopped a new label from invalidating.
464
- */
413
+ // Keep the order stable: #buildCacheKey hashes this text. `lib` counts because dictionaries hold user-visible text.
465
414
  async #collectAutoSubsetText() {
466
415
  //* Synced lib pages hold app-visible text too, and a glob never crosses the symlink that mounts them.
467
416
  const libPageRoots = (await this.#app.getPageRoots()).filter((root) => root.keyPrefix).map((root) => root.dir);
@@ -481,12 +430,6 @@ export class FontOptimizer {
481
430
  return parts.join("");
482
431
  }
483
432
 
484
- #rangeText(start: number, end: number) {
485
- let text = "";
486
- for (let code = start; code <= end; code++) text += String.fromCodePoint(code);
487
- return text;
488
- }
489
-
490
433
  static #getKsX1001Text() {
491
434
  if (FontOptimizer.#ksX1001Text) return FontOptimizer.#ksX1001Text;
492
435
  try {
@@ -500,12 +443,12 @@ export class FontOptimizer {
500
443
  }
501
444
  FontOptimizer.#ksX1001Text = [...chars].join("");
502
445
  } catch {
503
- FontOptimizer.#ksX1001Text = FontOptimizer.#rangeTextStatic(0xac00, 0xd7a3);
446
+ FontOptimizer.#ksX1001Text = FontOptimizer.#rangeText(0xac00, 0xd7a3);
504
447
  }
505
448
  return FontOptimizer.#ksX1001Text;
506
449
  }
507
450
 
508
- static #rangeTextStatic(start: number, end: number) {
451
+ static #rangeText(start: number, end: number) {
509
452
  let text = "";
510
453
  for (let code = start; code <= end; code++) text += String.fromCodePoint(code);
511
454
  return text;
@@ -1,11 +1,11 @@
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";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import type { App } from "../commandDecorators";
5
+ import { tempDirs } from "../testHelpers";
6
6
  import { FontPruner } from "./fontPruner";
7
7
 
8
- const tempRoots: string[] = [];
8
+ const tempRoot = tempDirs("akan-devkit-font-prune-");
9
9
 
10
10
  interface Tree {
11
11
  layout?: string;
@@ -23,8 +23,7 @@ const write = async (root: string, files: Record<string, string>) => {
23
23
  };
24
24
 
25
25
  const makeApp = async ({ layout = layoutWith(), publicFiles = {}, artifactFiles = {}, csrFiles = {} }: Tree = {}) => {
26
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-font-prune-"));
27
- tempRoots.push(root);
26
+ const root = await tempRoot();
28
27
  const cwdPath = path.join(root, "apps/demo");
29
28
  const distPath = path.join(root, "dist/apps/demo");
30
29
  await mkdir(path.join(cwdPath, "page"), { recursive: true });
@@ -58,10 +57,6 @@ export default function Layout() {
58
57
 
59
58
  const exists = (distPath: string, rel: string) => Bun.file(path.join(distPath, "public", rel)).exists();
60
59
 
61
- afterEach(async () => {
62
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
63
- });
64
-
65
60
  describe("FontPruner", () => {
66
61
  test("drops a subset font's source and reports the bytes freed", async () => {
67
62
  const { app, distPath } = await makeApp({
@@ -18,12 +18,7 @@ export interface FontPruneResult {
18
18
  freedBytes: number;
19
19
  }
20
20
 
21
- /**
22
- * Drops the font sources in a build's `public/` that no built surface reads. A font with `optimize` on is
23
- * served from `/_akan/fonts` after subsetting, so its source is a build input — the image ships it and the
24
- * runtime never opens it. Only `dist` is touched: an app's and a lib's own `public/` keep every file, because
25
- * one lib's fonts are picked over differently by every app that mounts it and by other repos.
26
- */
21
+ /** Deletes the fonts in the build's `dist/…/public` that nothing reads; source `public/` trees are never touched. */
27
22
  export class FontPruner {
28
23
  #app: App;
29
24
  #keepGlobs: string[];
@@ -92,15 +87,8 @@ export class FontPruner {
92
87
  return candidates;
93
88
  }
94
89
 
95
- /**
96
- * Matched by basename rather than by URL, so a reference survives every spelling a referrer may use — an
97
- * absolute or relative `url()`, and the percent-encoded form a font whose filename carries a space needs.
98
- *
99
- * The build's own bundles are read on weaker terms than `public/` and the compiled CSS: every route file's
100
- * `fonts` declaration is inlined into the pages bundle, the client chunks and the CSR shell, so a declared
101
- * source is in all three whether or not anything loads it. A hit there is therefore ignored for a font the
102
- * optimizer already subset — that string is the declaration, not a fetch.
103
- */
90
+ // Basename match (plain and percent-encoded) covers every url() spelling. The bundles inline each route's `fonts`
91
+ // declaration, so a bundle hit counts only for a font the optimizer did not subset.
104
92
  async #findReferrers(
105
93
  candidates: { rel: string; basename: string }[],
106
94
  optimizedSrcs: Set<string>,
@@ -155,11 +143,8 @@ export class FontPruner {
155
143
  for (const dir of dirs) await rmdir(path.join(this.#publicRoot, dir)).catch(() => undefined);
156
144
  }
157
145
 
158
- /**
159
- * The referrer roll-up is `info`, not `verbose`: a generated file that lists every asset in `public/` — a
160
- * service-worker precache manifest is the usual one — is a real reference and keeps every font it names, so
161
- * without this line a build that pruned nothing looks the same as a build with nothing to prune.
162
- */
146
+ // The roll-up is `info`: a precache manifest listing all of public/ keeps every font, which otherwise looks like
147
+ // a build with nothing to prune.
163
148
  #report(result: FontPruneResult) {
164
149
  if (!result.removed.length && !result.kept.length) return;
165
150
  for (const { file, bytes } of result.removed)
@@ -1,9 +1,9 @@
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 { readFile, rm } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
  import type { RoutesManifest } from "akanjs/server";
6
+ import { tempDirs, writeText as write } from "../testHelpers";
7
7
  import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
8
  import { CssCompiler, declaredCustomProperties, isIgnoredNodeModuleSource } from "./cssCompiler";
9
9
  import { CssImportResolver } from "./cssImportResolver";
@@ -15,33 +15,19 @@ import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
15
15
  import { RoutesManifestArtifactSerializer } from "./routesManifestArtifactSerializer";
16
16
  import { prepareCssAsset } from "./ssrBaseArtifactBuilder";
17
17
 
18
- const tempRoots: string[] = [];
19
-
20
- const makeTempRoot = async () => {
21
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-frontend-"));
22
- tempRoots.push(root);
23
- return root;
24
- };
25
-
26
- const write = async (filePath: string, content: string) => {
27
- await mkdir(path.dirname(filePath), { recursive: true });
28
- await writeFile(filePath, content);
29
- };
30
-
31
- afterEach(async () => {
32
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
33
- });
18
+ const makeTempRoot = tempDirs("akan-devkit-frontend-");
34
19
 
35
20
  describe("PagesEntrySourceGenerator", () => {
36
21
  const toSpecifier = (absPath: string) => path.resolve(absPath).split(path.sep).join("/");
22
+ const indexAbs = path.resolve("/repo/apps/demo/page/_index.tsx");
23
+ const adminAbs = path.resolve("/repo/apps/demo/page/admin.tsx");
24
+ const entries = [
25
+ { key: "./_index.tsx", moduleAbsPath: indexAbs },
26
+ { key: "./admin.tsx", moduleAbsPath: adminAbs },
27
+ ];
37
28
 
38
29
  test("generates dynamic import source using forward-slash module paths", () => {
39
- const indexAbs = path.resolve("/repo/apps/demo/page/_index.tsx");
40
- const adminAbs = path.resolve("/repo/apps/demo/page/admin.tsx");
41
- const source = PagesEntrySourceGenerator.generate([
42
- { key: "./_index.tsx", moduleAbsPath: indexAbs },
43
- { key: "./admin.tsx", moduleAbsPath: adminAbs },
44
- ]);
30
+ const source = PagesEntrySourceGenerator.generate(entries);
45
31
 
46
32
  expect(source).toBe(
47
33
  [
@@ -55,12 +41,7 @@ describe("PagesEntrySourceGenerator", () => {
55
41
  });
56
42
 
57
43
  test("generates static import source for single-file CSR bundles", async () => {
58
- const indexAbs = path.resolve("/repo/apps/demo/page/_index.tsx");
59
- const adminAbs = path.resolve("/repo/apps/demo/page/admin.tsx");
60
- const source = await PagesEntrySourceGenerator.generateStatic([
61
- { key: "./_index.tsx", moduleAbsPath: indexAbs },
62
- { key: "./admin.tsx", moduleAbsPath: adminAbs },
63
- ]);
44
+ const source = await PagesEntrySourceGenerator.generateStatic(entries);
64
45
 
65
46
  expect(source).toBe(
66
47
  [
@@ -501,6 +482,14 @@ describe("CssImportResolver", () => {
501
482
  });
502
483
 
503
484
  describe("CssCompiler", () => {
485
+ const cssCompilerFor = (root: string, cwdPath: string, app: object = {}) =>
486
+ new CssCompiler({
487
+ workspace: { workspaceRoot: root },
488
+ cwdPath,
489
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
490
+ ...app,
491
+ } as never);
492
+
504
493
  test("scans installed akanjs sources while ignoring other node_modules", async () => {
505
494
  expect(isIgnoredNodeModuleSource("/repo/node_modules/react/index.js")).toBe(true);
506
495
  expect(isIgnoredNodeModuleSource("/repo/node_modules/akanjs/ui/Button.tsx")).toBe(false);
@@ -514,13 +503,8 @@ describe("CssCompiler", () => {
514
503
  await write(cssPath, `@import ${JSON.stringify(tailwindCssPath)};\n@source "./**/*";\n`);
515
504
  await write(uiSource, 'export const Button = () => <button className="text-fuchsia-500" />;\n');
516
505
 
517
- const compiler = new CssCompiler({
518
- workspace: { workspaceRoot: root },
519
- // The candidate cache lands under `<cwdPath>/.akan/cache`, so point it at this test's temp root
520
- // rather than wherever the suite happens to run from.
521
- cwdPath: root,
522
- getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
523
- } as never);
506
+ // The candidate cache lands under `<cwdPath>/.akan/cache`, so keep it inside this test's temp root.
507
+ const compiler = cssCompilerFor(root, root);
524
508
  const css = await compiler.compileCss([cssPath], []);
525
509
 
526
510
  expect(css).toContain(".text-fuchsia-500");
@@ -542,11 +526,7 @@ describe("CssCompiler", () => {
542
526
  const cssPath = path.join(root, "apps/demo/page/styles.css");
543
527
  await write(cssPath, '@import "../../../libs/shared/ui/tokens.css";\n');
544
528
 
545
- const compiler = new CssCompiler({
546
- workspace: { workspaceRoot: root },
547
- cwdPath: path.join(root, "apps/demo"),
548
- getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
549
- } as never);
529
+ const compiler = cssCompilerFor(root, path.join(root, "apps/demo"));
550
530
 
551
531
  await expect(compiler.compileCss([cssPath], [])).rejects.toThrow(
552
532
  /failed to resolve stylesheet import "\.\.\/\.\.\/\.\.\/libs\/shared\/ui\/tokens\.css"/,
@@ -560,13 +540,10 @@ describe("CssCompiler", () => {
560
540
  await write(path.join(appDir, "page/styles.css"), '@import "../../../libs/shared/ui/brand.css";\n');
561
541
  await write(path.join(root, "libs/shared/ui/brand.css"), ":root { --kakao: #fee500; --naver: #1ec800; }\n");
562
542
 
563
- const compiler = new CssCompiler({
564
- workspace: { workspaceRoot: root },
565
- cwdPath: appDir,
543
+ const compiler = cssCompilerFor(root, appDir, {
566
544
  getPageKeys: async () => ["./_index.tsx"],
567
545
  getConfig: async () => ({ barrelImports: [], basePaths: [] }),
568
- getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
569
- } as never);
546
+ });
570
547
  const cssByBasePath = await compiler.getCssByBasePath();
571
548
 
572
549
  expect(cssByBasePath[""]).toContain("--kakao");
@@ -587,13 +564,11 @@ describe("CssCompiler", () => {
587
564
  await write(path.join(root, "libs/shared/ui/tokens.css"), ":root { --kakao: #fee500; }\n");
588
565
  await write(path.join(root, "libs/unused/ui/tokens.css"), ":root { --unused: #000000; }\n");
589
566
 
590
- const compiler = new CssCompiler({
591
- workspace: { workspaceRoot: root },
592
- cwdPath: appDir,
567
+ const compiler = cssCompilerFor(root, appDir, {
593
568
  getPageKeys: async () => ["./_index.tsx"],
594
569
  getConfig: async () => ({ barrelImports: [] }),
595
570
  getTsConfig: async () => ({ compilerOptions: { paths: { "@libs/*": ["./libs/*"] } } }),
596
- } as never);
571
+ });
597
572
  const { cssPaths } = await compiler.discoverCssAndSources();
598
573
 
599
574
  expect(cssPaths).toEqual([path.join(root, "libs/shared/ui/tokens.css"), path.join(appDir, "page/styles.css")]);
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import type { ChangeKind } from "akanjs/server";
3
+ import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
3
4
 
4
- const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
5
5
  const CSS_EXTS = new Set([".css"]);
6
6
  const CONFIG_BASENAMES = new Set(["akan.config.ts", "bunfig.toml", "tsconfig.json", "package.json"]);
7
7
 
@@ -19,7 +19,7 @@ export class HmrChangeClassifier {
19
19
  #isUninteresting(abs: string): boolean {
20
20
  const base = path.basename(abs);
21
21
  if (!base) return true;
22
- if (base.startsWith(".")) return true; // .git, .DS_Store, vim swaps, etc.
22
+ if (base.startsWith(".")) return true;
23
23
  if (base.endsWith("~") || base.endsWith(".swp") || base.endsWith(".swx") || base.endsWith(".tmp")) return true;
24
24
  if (abs.includes(`${path.sep}node_modules${path.sep}`)) return true;
25
25
  if (abs.includes(`${path.sep}.akan${path.sep}`)) return true;
@@ -1,24 +1,17 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
- import os from "node:os";
2
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import type { Logger } from "akanjs/common";
5
+ import { tempDirs } from "../testHelpers";
6
6
  import { type ChangeBatch, HmrWatcher } from "./hmrWatcher";
7
7
 
8
- /**
9
- * These run against the real Bun watcher rather than a fake, because the behaviour under test only
10
- * exists there: Bun 1.3.14's recursive `fs.watch` reports about one path per ~200ms coalescing window and
11
- * discards the rest (`local/optimize-resource/06-watcher-dropped-event.md`). A fake that delivered every
12
- * event would pass no matter what the watcher did with them.
13
- */
8
+ // The real Bun watcher, not a fake: the dropped-event behaviour under test only exists there.
14
9
  const STREAM_WARMUP_MS = 600;
15
10
  const SETTLE_MS = 1_500;
16
11
  const TEST_TIMEOUT_MS = 15_000;
17
12
 
18
13
  const started: HmrWatcher[] = [];
19
- const roots: string[] = [];
20
14
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
21
- /** Every level `HmrWatcher` can reach, so adding a log line cannot fail a test for the wrong reason. */
22
15
  const silentLogger = {
23
16
  trace: () => undefined,
24
17
  verbose: () => undefined,
@@ -29,12 +22,6 @@ const silentLogger = {
29
22
  error: () => undefined,
30
23
  } as unknown as Logger;
31
24
 
32
- const makeRoot = async () => {
33
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-hmr-watcher-"));
34
- roots.push(root);
35
- return root;
36
- };
37
-
38
25
  const seed = async (root: string, rel: string, content = "export const x = 1;\n") => {
39
26
  const abs = path.join(root, rel);
40
27
  await mkdir(path.dirname(abs), { recursive: true });
@@ -56,10 +43,10 @@ const watch = async (root: string) => {
56
43
  return { watcher, batches, seen: () => new Set(batches.flatMap((batch) => batch.files)) };
57
44
  };
58
45
 
59
- afterEach(async () => {
46
+ afterEach(() => {
60
47
  for (const watcher of started.splice(0)) watcher.stop();
61
- await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
62
48
  });
49
+ const makeRoot = tempDirs("akan-hmr-watcher-");
63
50
 
64
51
  describe("HmrWatcher", () => {
65
52
  test(
@@ -69,15 +56,12 @@ describe("HmrWatcher", () => {
69
56
  const files = await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
70
57
  const { batches, seen, watcher } = await watch(root);
71
58
 
72
- // No gaps between writes, so they share one coalescing window and Bun names at most one of them.
73
59
  for (const [i, abs] of files.entries()) await writeFile(abs, `export const x = ${i}00;\n`);
74
60
  await sleep(SETTLE_MS);
75
61
 
76
62
  expect(batches.length).toBeGreaterThan(0);
77
63
  for (const abs of files) expect([...seen()]).toContain(abs);
78
- // Not asserted as non-zero: the point is that the outcome above holds whether or not Bun drops
79
- // events, so this suite keeps passing if the upstream defect is ever fixed. The counter is the
80
- // signal for when the compensation can be removed.
64
+ // Not `> 0`: the suite must keep passing if Bun ever stops dropping events.
81
65
  expect(watcher.unreportedChanges).toBeGreaterThanOrEqual(0);
82
66
  },
83
67
  TEST_TIMEOUT_MS,
@@ -90,8 +74,6 @@ describe("HmrWatcher", () => {
90
74
  const source = await seed(root, "lib/a.ts");
91
75
  const { seen, batches } = await watch(root);
92
76
 
93
- // What every build ends with: a burst under `.akan/`, which the classifier ignores. Before this
94
- // was handled, the burst was the one path Bun reported and the save right after it was invisible.
95
77
  const artifactDir = path.join(root, ".akan", "artifact", "server");
96
78
  await mkdir(artifactDir, { recursive: true });
97
79
  for (let i = 0; i < 60; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(8192));
@@ -100,8 +82,6 @@ describe("HmrWatcher", () => {
100
82
  await sleep(SETTLE_MS);
101
83
 
102
84
  expect([...seen()]).toContain(source);
103
- // Exactly one batch for one save. Bun does deliver a real event for some paths the scan has already
104
- // emitted, and counting both produced a second generation and a second build for no change.
105
85
  expect(batches.filter((batch) => batch.files.includes(source))).toHaveLength(1);
106
86
  },
107
87
  TEST_TIMEOUT_MS,
@@ -119,7 +99,6 @@ describe("HmrWatcher", () => {
119
99
  for (let i = 0; i < 20; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(4096));
120
100
  await sleep(SETTLE_MS);
121
101
 
122
- // The verification scan is triggered by ignored paths, so it must not invent work from them either.
123
102
  expect(batches).toEqual([]);
124
103
  },
125
104
  TEST_TIMEOUT_MS,
@@ -176,12 +155,9 @@ describe("HmrWatcher", () => {
176
155
  });
177
156
  started.push(watcher);
178
157
  await watcher.start();
179
- // Restored before asserting, not after: a failed assertion would otherwise leave a directory `rm`
180
- // cannot traverse, and the leak would fail the *next* test instead of this one.
158
+ // Restored before asserting: a failed assertion would leave a directory `rm` cannot traverse.
181
159
  await chmod(path.dirname(hidden), 0o755);
182
160
 
183
- // At startup rather than at the first save, because an unreadable root means edits under it never
184
- // rebuild — waiting for a save to reveal that means waiting for the save that silently does nothing.
185
161
  expect(warnings).toHaveLength(1);
186
162
  expect(warnings[0]).toContain("will not rebuild");
187
163
  expect(warnings[0]).toContain("EACCES");