@akanjs/devkit 3.0.0-beta.2 → 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 (265) hide show
  1. package/README.md +1 -0
  2. package/abstractDoc.test.ts +6 -15
  3. package/abstractDoc.ts +6 -8
  4. package/agentsIndex.test.ts +4 -7
  5. package/agentsIndex.ts +6 -22
  6. package/akanApp/BackendImportGraph.test.ts +8 -22
  7. package/akanApp/BackendImportGraph.ts +1 -20
  8. package/akanApp/akanApp.host.test.ts +1 -3
  9. package/akanApp/akanApp.host.ts +82 -254
  10. package/akanApp/devHostPolicy.test.ts +10 -34
  11. package/akanApp/devHostPolicy.ts +12 -98
  12. package/akanConfig/akanConfig.test.ts +85 -69
  13. package/akanConfig/akanConfig.ts +69 -153
  14. package/akanConfig/types.ts +1 -0
  15. package/akanContext.ts +106 -159
  16. package/akanMcpContract.ts +28 -73
  17. package/appSelectionMemory.ts +1 -4
  18. package/applicationBuildReporter.ts +37 -5
  19. package/applicationBuildRunner.test.ts +5 -3
  20. package/applicationBuildRunner.ts +54 -61
  21. package/applicationReleasePackager.ts +24 -44
  22. package/applicationTestPreload.ts +3 -12
  23. package/artifact/implicitRootLayout.test.ts +9 -30
  24. package/artifact/implicitRootLayout.ts +34 -106
  25. package/artifact/routeSeedIndex.test.ts +7 -6
  26. package/artifact/routeSeedIndex.ts +11 -44
  27. package/biome.base.json +6 -1
  28. package/biomeBase.ts +2 -5
  29. package/capacitorApp.test.ts +32 -16
  30. package/capacitorApp.ts +110 -238
  31. package/cloud/cloudApi.test.ts +202 -0
  32. package/cloud/cloudApi.ts +77 -39
  33. package/cloud/configLock.test.ts +64 -0
  34. package/cloud/configLock.ts +96 -0
  35. package/cloud/constants.ts +26 -5
  36. package/cloud/globalConfig.ts +70 -32
  37. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  38. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  39. package/codeAgent/agent/CodeAgent.ts +551 -0
  40. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  41. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  42. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  43. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  44. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  45. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  46. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  47. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  48. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  49. package/codeAgent/agent/CodeMailbox.ts +148 -0
  50. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  51. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  52. package/codeAgent/agent/SubagentPool.ts +214 -0
  53. package/codeAgent/agent/akanCodeModel.ts +47 -0
  54. package/codeAgent/agent/akanCodePaths.ts +31 -0
  55. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  56. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  57. package/codeAgent/agent/codeAgent.test.ts +178 -0
  58. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  59. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  60. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  61. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  62. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  63. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  64. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  65. package/codeAgent/agent/consoleToStderr.ts +8 -0
  66. package/codeAgent/agent/jsonLines.ts +29 -0
  67. package/codeAgent/agent/stringArg.ts +5 -0
  68. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  69. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  70. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  71. package/codeAgent/feedback/PreviewView.ts +95 -0
  72. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  73. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  74. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  75. package/codeAgent/index.ts +34 -0
  76. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  77. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  78. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  79. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  80. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  81. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  82. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  83. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  84. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  85. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  86. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  87. package/codeAgent/tools/AkanCli.ts +50 -0
  88. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  89. package/codeAgent/tools/AkanEditScope.ts +48 -0
  90. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  91. package/codeAgent/tools/AkanToolPack.ts +92 -0
  92. package/codeAgent/tools/AkanVerifier.ts +48 -0
  93. package/codeAgent/tools/AskToolPack.ts +99 -0
  94. package/codeAgent/tools/MailToolPack.ts +76 -0
  95. package/codeAgent/tools/McpClient.ts +187 -0
  96. package/codeAgent/tools/McpOAuth.ts +233 -0
  97. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  98. package/codeAgent/tools/McpServerConfig.ts +129 -0
  99. package/codeAgent/tools/McpSignIn.ts +158 -0
  100. package/codeAgent/tools/McpTokenStore.ts +81 -0
  101. package/codeAgent/tools/McpToolPack.ts +199 -0
  102. package/codeAgent/tools/SessionToolPack.ts +91 -0
  103. package/codeAgent/tools/WebToolPack.ts +147 -0
  104. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  105. package/codeAgent/tools/askToolPack.test.ts +116 -0
  106. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  107. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  108. package/codegenLock.test.ts +4 -13
  109. package/codegenLock.ts +3 -17
  110. package/commandDecorators/argMeta.ts +1 -5
  111. package/commandDecorators/camelToKebabCase.ts +1 -0
  112. package/commandDecorators/command.ts +46 -102
  113. package/commandDecorators/commandBuilder.ts +30 -61
  114. package/commandDecorators/commandDecorators.test.ts +2 -10
  115. package/commandDecorators/dependencyBuilder.ts +10 -1
  116. package/commandDecorators/helpFormatter.ts +68 -145
  117. package/commandDecorators/index.ts +0 -2
  118. package/commandDecorators/targetMeta.ts +9 -7
  119. package/createTunnel.ts +7 -14
  120. package/dependencyScanner.test.ts +4 -16
  121. package/dependencyScanner.ts +31 -80
  122. package/devkitUtils.test.ts +48 -20
  123. package/executors.linkFallback.test.ts +103 -0
  124. package/executors.test.ts +132 -106
  125. package/executors.ts +194 -484
  126. package/fileEditor.ts +9 -37
  127. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  128. package/fileSys.ts +6 -8
  129. package/formSetterScanner.ts +3 -16
  130. package/frontendBuild/allRoutesBuilder.ts +7 -21
  131. package/frontendBuild/autoImportSync.test.ts +27 -38
  132. package/frontendBuild/autoImportSync.ts +31 -65
  133. package/frontendBuild/buildRouteClient.test.ts +62 -37
  134. package/frontendBuild/bundleDefine.ts +12 -0
  135. package/frontendBuild/clientBuildTypes.ts +5 -36
  136. package/frontendBuild/clientEntriesBundler.ts +9 -40
  137. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  138. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  139. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  140. package/frontendBuild/cssCandidateCache.ts +6 -41
  141. package/frontendBuild/cssCompiler.ts +29 -78
  142. package/frontendBuild/cssImportResolver.ts +6 -19
  143. package/frontendBuild/devChangePlanner.ts +7 -10
  144. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  145. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  146. package/frontendBuild/fontOptimizer.test.ts +6 -14
  147. package/frontendBuild/fontOptimizer.ts +15 -72
  148. package/frontendBuild/fontPruner.test.ts +5 -10
  149. package/frontendBuild/fontPruner.ts +5 -20
  150. package/frontendBuild/frontendBuild.test.ts +46 -69
  151. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  152. package/frontendBuild/hmrWatcher.test.ts +8 -32
  153. package/frontendBuild/hmrWatcher.ts +14 -71
  154. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  155. package/frontendBuild/precompressArtifacts.ts +5 -18
  156. package/frontendBuild/routeClientBuilder.ts +23 -46
  157. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  158. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  159. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  160. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  161. package/frontendBuild/styleContract.ts +1 -8
  162. package/frontendBuild/themeValidator.ts +4 -26
  163. package/frontendBuild/watchRootResolver.test.ts +23 -42
  164. package/frontendBuild/watchRootResolver.ts +5 -27
  165. package/getRelatedCnsts.ts +12 -92
  166. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  167. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  168. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  169. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  170. package/incrementalBuilder/builderChannel.test.ts +24 -49
  171. package/incrementalBuilder/builderChannel.ts +6 -37
  172. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  173. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  174. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  175. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  176. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  177. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  178. package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
  179. package/index.ts +5 -17
  180. package/integration/devResourceProbe.ts +50 -56
  181. package/integration/devStability.integration.test.ts +47 -194
  182. package/integration/devStabilityHarness.test.ts +13 -28
  183. package/integration/devStabilityHarness.ts +65 -330
  184. package/integration/ssrMemoryProbe.ts +41 -167
  185. package/libSource.test.ts +4 -15
  186. package/libSource.ts +19 -31
  187. package/lint/gritRules.test.ts +8 -24
  188. package/lint/no-deep-internal-import.grit +2 -2
  189. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  190. package/linter.test.ts +1 -2
  191. package/linter.ts +26 -116
  192. package/mobile/mobileTarget.ts +14 -13
  193. package/package.json +10 -5
  194. package/packageExports.test.ts +3 -18
  195. package/packageExportsMap.ts +3 -19
  196. package/piDependencyPins.test.ts +46 -0
  197. package/platformTest/BunTestReport.test.ts +60 -0
  198. package/platformTest/BunTestReport.ts +65 -0
  199. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  200. package/platformTest/IdleWatchedProcess.ts +73 -0
  201. package/platformTest/LinuxDockerTarget.ts +105 -0
  202. package/platformTest/PlatformTestRun.test.ts +64 -0
  203. package/platformTest/PlatformTestRun.ts +141 -0
  204. package/platformTest/PlatformTestTarget.ts +142 -0
  205. package/platformTest/TestSnapshot.test.ts +72 -0
  206. package/platformTest/TestSnapshot.ts +103 -0
  207. package/platformTest/WindowsSshTarget.ts +151 -0
  208. package/prompter.ts +3 -13
  209. package/qualityScanner.test.ts +9 -26
  210. package/qualityScanner.ts +197 -285
  211. package/recipeScanner.test.ts +7 -12
  212. package/recipeScanner.ts +9 -38
  213. package/repoIdentity.ts +2 -9
  214. package/routeSourceValidator.test.ts +0 -1
  215. package/routeSourceValidator.ts +7 -42
  216. package/scanInfo.ts +87 -216
  217. package/selectModel.ts +1 -2
  218. package/semver.ts +3 -5
  219. package/slicePlanner.test.ts +6 -24
  220. package/slicePlanner.ts +11 -32
  221. package/spinner.test.ts +3 -8
  222. package/spinner.ts +4 -16
  223. package/sshTunnel.ts +1 -6
  224. package/ssrScanner.test.ts +1 -6
  225. package/ssrScanner.ts +103 -113
  226. package/stripAnsi.ts +3 -0
  227. package/subspace.test.ts +30 -25
  228. package/subspace.ts +81 -115
  229. package/subspaceConfig.ts +11 -9
  230. package/testHelpers.ts +194 -0
  231. package/transforms/asyncDefaultExportDetector.ts +3 -11
  232. package/transforms/barrelAnalyzer.ts +43 -135
  233. package/transforms/barrelImportsPlugin.ts +38 -175
  234. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  235. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  236. package/transforms/moduleSyntax.ts +32 -0
  237. package/transforms/rscUseClientTransform.ts +35 -20
  238. package/transforms/transforms.test.ts +153 -44
  239. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  240. package/transforms/useClientBundlePlugin.ts +2 -22
  241. package/typeChecker.ts +7 -63
  242. package/types.ts +1 -0
  243. package/uploadRelease.ts +10 -25
  244. package/workflow/artifacts.ts +18 -0
  245. package/workflow/executor.test.ts +1 -12
  246. package/workflow/executor.ts +101 -110
  247. package/workflow/index.ts +0 -1
  248. package/workflow/moduleIndex.test.ts +4 -15
  249. package/workflow/moduleIndex.ts +9 -49
  250. package/workflow/plan.ts +18 -25
  251. package/workflow/render.ts +80 -96
  252. package/workflow/rolloutGate.test.ts +1 -1
  253. package/workflow/source.ts +52 -69
  254. package/workflow/sourceAst.ts +50 -0
  255. package/workspaceLayout.ts +4 -19
  256. package/abstractCompactor.ts +0 -112
  257. package/aiEditor.test.ts +0 -60
  258. package/aiEditor.ts +0 -387
  259. package/commandDecorators/commandMeta.ts +0 -7
  260. package/commandDecorators/types.ts +0 -10
  261. package/getDirname.ts +0 -4
  262. package/guideline.ts +0 -19
  263. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  264. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  265. package/workflow/primitive.ts +0 -59
@@ -1,18 +1,4 @@
1
- /**
2
- * WCAG contrast checking over the semantic token pairs. No dependencies, pure functions, and no akanjs
3
- * runtime import — it has to run inside the lint path.
4
- *
5
- * The `--x` / `--x-foreground` pairing is what makes the check possible at all: it says which two values
6
- * are going to end up on top of each other. That is the guard against a generated palette shipping a site
7
- * nobody can read.
8
- *
9
- * Thresholds (WCAG 2.1):
10
- * - body and primary surfaces (background, primary, secondary, accent, neutral, card, popover): 4.5:1,
11
- * the AA floor for normal text
12
- * - status and secondary pairs (info, success, warning, destructive, open, muted): 3:1, the AA floor for
13
- * UI components and large text
14
- * The shipped light/dark palette in styles.css clears all of them.
15
- */
1
+ // No akanjs runtime import: this runs inside the lint path.
16
2
 
17
3
  export interface ThemeContrastViolation {
18
4
  scope: string;
@@ -30,6 +16,7 @@ interface PairDef {
30
16
  threshold: number;
31
17
  }
32
18
 
19
+ // WCAG 2.1 AA: 4.5:1 is the floor for normal text, 3:1 for UI components and large text.
33
20
  const PAIRS: PairDef[] = [
34
21
  { base: "background", fg: "foreground", threshold: 4.5 },
35
22
  { base: "primary", fg: "primary-foreground", threshold: 4.5 },
@@ -46,13 +33,11 @@ const PAIRS: PairDef[] = [
46
33
  { base: "open", fg: "open-foreground", threshold: 3 },
47
34
  ];
48
35
 
49
- // Only these scopes are paired up; tokens scoped to something else (`.campaign-x`, …) are left alone.
50
36
  const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
51
37
 
52
38
  export type ThemeTokensByScope = Record<string, Record<string, string>>;
53
39
 
54
40
  export class ThemeValidator {
55
- /** Extracts tokens from the css text and checks every scope it recognizes. */
56
41
  validate(css: string): ThemeContrastViolation[] {
57
42
  const tokensByScope = ThemeValidator.parseThemeTokens(css);
58
43
  const violations: ThemeContrastViolation[] = [];
@@ -71,7 +56,7 @@ export class ThemeValidator {
71
56
  if (!bg || !front) continue;
72
57
  const bgRgb = ThemeValidator.parseHex(bg);
73
58
  const fgRgb = ThemeValidator.parseHex(front);
74
- if (!bgRgb || !fgRgb) continue; // a var() or non-hex value has no ratio to compute
59
+ if (!bgRgb || !fgRgb) continue;
75
60
  const ratio = ThemeValidator.contrastRatio(bgRgb, fgRgb);
76
61
  if (ratio >= threshold) continue;
77
62
  violations.push({
@@ -87,15 +72,9 @@ export class ThemeValidator {
87
72
  return violations;
88
73
  }
89
74
 
90
- /**
91
- * Reads `--token: value` out of `:root` / `[data-theme="…"]` blocks. A grouped selector
92
- * (`:root, [data-theme="dark"] { … }`) distributes the same tokens to each selector, and a scope that
93
- * appears twice keeps the later value — so passing framework css first and the app's second reflects the
94
- * app's overrides.
95
- */
75
+ /** A scope declared twice keeps the later value, so pass the framework css before the app's. */
96
76
  static parseThemeTokens(css: string): ThemeTokensByScope {
97
77
  const result: ThemeTokensByScope = {};
98
- // Flat rule blocks only; an at-rule (@theme, @keyframes) carries `@` in the selector and is skipped.
99
78
  const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
100
79
  for (const block of css.matchAll(blockRe)) {
101
80
  const selectors = block[1].split(",").map((s) => s.trim());
@@ -114,7 +93,6 @@ export class ThemeValidator {
114
93
  }
115
94
 
116
95
  static #normalizeScope(selector: string): string {
117
- // Quote normalization: [data-theme=dark] / [data-theme='dark'] -> [data-theme="dark"]
118
96
  return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
119
97
  }
120
98
 
@@ -1,15 +1,14 @@
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, 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 { WatchRootResolver } from "./watchRootResolver";
7
7
 
8
- const roots: string[] = [];
8
+ const tempRoot = tempDirs("akan-watch-roots-");
9
9
 
10
10
  const makeWorkspace = async (dirs: string[]) => {
11
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-watch-roots-"));
12
- roots.push(root);
11
+ const root = await tempRoot();
13
12
  await Promise.all(dirs.map((dir) => mkdir(path.join(root, dir), { recursive: true })));
14
13
  return root;
15
14
  };
@@ -25,24 +24,18 @@ interface AppStub {
25
24
  libDeps?: string[] | null;
26
25
  }
27
26
 
28
- const makeApp = (workspaceRoot: string, name: string, { paths = {}, libDeps = null }: AppStub = {}) =>
29
- ({
30
- cwdPath: path.join(workspaceRoot, "apps", name),
27
+ const resolveRoots = (workspaceRoot: string, { paths = {}, libDeps = null }: AppStub = {}) =>
28
+ new WatchRootResolver({
29
+ cwdPath: path.join(workspaceRoot, "apps", "app1"),
31
30
  workspace: { workspaceRoot },
32
31
  getTsConfig: async () => ({ compilerOptions: { paths } }),
33
32
  getScanInfo: () => (libDeps ? { type: "app", libDeps } : null),
34
- }) as unknown as App;
35
-
36
- afterEach(async () => {
37
- await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
38
- });
33
+ } as unknown as App).resolve();
39
34
 
40
35
  describe("WatchRootResolver", () => {
41
36
  test("narrows the apps container to the app being served", async () => {
42
37
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "apps/app2/page", "libs/util"]);
43
- const resolved = await new WatchRootResolver(
44
- makeApp(workspaceRoot, "app1", { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } }),
45
- ).resolve();
38
+ const resolved = await resolveRoots(workspaceRoot, { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } });
46
39
 
47
40
  expect(resolved).toContain(path.join(workspaceRoot, "apps/app1"));
48
41
  expect(resolved).not.toContain(path.join(workspaceRoot, "apps"));
@@ -51,9 +44,7 @@ describe("WatchRootResolver", () => {
51
44
 
52
45
  test("narrows the libs container to the app's own dependencies", async () => {
53
46
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
54
- const resolved = await new WatchRootResolver(
55
- makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] }, libDeps: ["util"] }),
56
- ).resolve();
47
+ const resolved = await resolveRoots(workspaceRoot, { paths: { "@libs/*": ["./libs/*"] }, libDeps: ["util"] });
57
48
 
58
49
  expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
59
50
  expect(resolved).not.toContain(path.join(workspaceRoot, "libs"));
@@ -63,9 +54,7 @@ describe("WatchRootResolver", () => {
63
54
  test("drops the libs container entirely when the app depends on no lib", async () => {
64
55
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util"]);
65
56
  await writeManifest(workspaceRoot, "apps/app1", []);
66
- const resolved = await new WatchRootResolver(
67
- makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
68
- ).resolve();
57
+ const resolved = await resolveRoots(workspaceRoot, { paths: { "@libs/*": ["./libs/*"] } });
69
58
 
70
59
  expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
71
60
  });
@@ -76,9 +65,7 @@ describe("WatchRootResolver", () => {
76
65
  await writeManifest(workspaceRoot, "libs/shared", ["util"]);
77
66
  await writeManifest(workspaceRoot, "libs/util", []);
78
67
  await writeManifest(workspaceRoot, "libs/unused", []);
79
- const resolved = await new WatchRootResolver(
80
- makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
81
- ).resolve();
68
+ const resolved = await resolveRoots(workspaceRoot, { paths: { "@libs/*": ["./libs/*"] } });
82
69
 
83
70
  expect(resolved).toContain(path.join(workspaceRoot, "libs/shared"));
84
71
  expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
@@ -87,9 +74,7 @@ describe("WatchRootResolver", () => {
87
74
 
88
75
  test("keeps the libs container whole when the app manifest is missing", async () => {
89
76
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
90
- const resolved = await new WatchRootResolver(
91
- makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
92
- ).resolve();
77
+ const resolved = await resolveRoots(workspaceRoot, { paths: { "@libs/*": ["./libs/*"] } });
93
78
 
94
79
  expect(resolved).toContain(path.join(workspaceRoot, "libs"));
95
80
  });
@@ -97,24 +82,20 @@ describe("WatchRootResolver", () => {
97
82
  test("keeps the libs container whole when a dependency lib is unsynced", async () => {
98
83
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
99
84
  await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
100
- const resolved = await new WatchRootResolver(
101
- makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
102
- ).resolve();
85
+ const resolved = await resolveRoots(workspaceRoot, { paths: { "@libs/*": ["./libs/*"] } });
103
86
 
104
87
  expect(resolved).toContain(path.join(workspaceRoot, "libs"));
105
88
  });
106
89
 
107
90
  test("resolves a package alias to its own directory, filename and glob stripped", async () => {
108
91
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "pkgs/akanjs/base"]);
109
- const resolved = await new WatchRootResolver(
110
- makeApp(workspaceRoot, "app1", {
111
- paths: {
112
- akanjs: ["./pkgs/akanjs/index.ts"],
113
- "akanjs/*": ["./pkgs/akanjs/*"],
114
- missing: ["./pkgs/nothing/index.ts"],
115
- },
116
- }),
117
- ).resolve();
92
+ const resolved = await resolveRoots(workspaceRoot, {
93
+ paths: {
94
+ akanjs: ["./pkgs/akanjs/index.ts"],
95
+ "akanjs/*": ["./pkgs/akanjs/*"],
96
+ missing: ["./pkgs/nothing/index.ts"],
97
+ },
98
+ });
118
99
 
119
100
  expect(resolved).toContain(path.join(workspaceRoot, "pkgs/akanjs"));
120
101
  expect(resolved).not.toContain(path.join(workspaceRoot, "pkgs/nothing"));
@@ -122,7 +103,7 @@ describe("WatchRootResolver", () => {
122
103
 
123
104
  test("always watches the app's page tree", async () => {
124
105
  const workspaceRoot = await makeWorkspace(["apps/app1/page"]);
125
- const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1")).resolve();
106
+ const resolved = await resolveRoots(workspaceRoot);
126
107
 
127
108
  expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
128
109
  });
@@ -3,23 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import type { App } from "../commandDecorators";
5
5
 
6
- /**
7
- * The directories one app's dev watcher follows, resolved from its tsconfig `paths`.
8
- *
9
- * Two aliases strip down to a workspace container rather than a package, and taking either verbatim puts
10
- * code the app never imports under the watcher — where a save rebuilds, restarts and reloads it for
11
- * nothing, and both builders rewrite the same generated barrels:
12
- *
13
- * - `@apps/*` becomes the `apps/` container. Apps are leaves of the workspace graph — never one another's
14
- * dependencies — so it is replaced by this app's own directory.
15
- * - `@libs/*` becomes the `libs/` container. A lib is a dependency only if the app reaches it, so it is
16
- * replaced by the app's own lib dependencies, transitively. The set is resolved once, at watcher
17
- * install: a lib that becomes a dependency mid-session needs a fresh `akan start` anyway, because the
18
- * import that made it one also needs a `sync`.
19
- *
20
- * Failing to resolve that set keeps the container whole, because a wrong narrowing is a dev server that
21
- * silently ignores edits.
22
- */
6
+ /** Watch roots from tsconfig paths: `@apps/*` narrows to this app, `@libs/*` to its lib deps (whole when unknown). */
23
7
  export class WatchRootResolver {
24
8
  #app: App;
25
9
 
@@ -32,14 +16,15 @@ export class WatchRootResolver {
32
16
  const appDir = path.resolve(this.#app.cwdPath);
33
17
  const appsContainer = path.dirname(appDir);
34
18
  const libsContainer = path.resolve(this.#app.workspace.workspaceRoot, "libs");
35
- const libRoots = await this.#resolveLibRoots(libsContainer);
19
+ const libRoots = (await this.#resolveLibDeps(libsContainer))
20
+ ?.map((name) => path.join(libsContainer, name))
21
+ .filter((dir) => fs.existsSync(dir));
36
22
  const set = new Set<string>();
37
23
  set.add(path.resolve(`${this.#app.cwdPath}/page`));
38
24
  for (const targets of Object.values(tsconfig.compilerOptions.paths ?? {})) {
39
25
  for (const target of targets) {
40
26
  if (!target) continue;
41
27
  if (path.isAbsolute(target)) continue;
42
- // Strip the trailing filename and glob so we watch the package root dir.
43
28
  const cleaned = target.replace(/\/?\*+.*$/, "").replace(/\/[^/]+\.[^/]+$/, "");
44
29
  const resolved = path.resolve(this.#app.workspace.workspaceRoot, cleaned);
45
30
  if (resolved === libsContainer && libRoots) {
@@ -53,16 +38,9 @@ export class WatchRootResolver {
53
38
  return [...set];
54
39
  }
55
40
 
56
- async #resolveLibRoots(libsContainer: string): Promise<string[] | null> {
57
- const libDeps = await this.#resolveLibDeps(libsContainer);
58
- if (!libDeps) return null;
59
- return libDeps.map((name) => path.join(libsContainer, name)).filter((dir) => fs.existsSync(dir));
60
- }
61
-
62
41
  async #resolveLibDeps(libsContainer: string): Promise<string[] | null> {
63
42
  const scanInfo = this.#app.getScanInfo({ allowEmpty: true });
64
- // Already transitive, and present whenever this runs in a process that scanned. The builder and the
65
- // idle watcher run in processes that did not, so they take the manifests `scan` wrote instead.
43
+ // Transitive, but only in a process that scanned; the builder and idle watcher read the synced manifests.
66
44
  if (scanInfo?.type === "app") return scanInfo.libDeps;
67
45
  const direct = await WatchRootResolver.#readManifestLibDeps(path.join(this.#app.cwdPath, "akan.app.json"));
68
46
  if (!direct) return null;
@@ -40,9 +40,6 @@ const scanModuleSpecifiers = (source: string, filePath: string, includeExports:
40
40
  return importSpecifiers;
41
41
  };
42
42
 
43
- /**
44
- * TypeScript 설정 파일을 읽고 파싱하는 함수
45
- */
46
43
  export const parseTsConfig = (tsConfigPath: string = "./tsconfig.json") => {
47
44
  const configFile = ts.readConfigFile(tsConfigPath, (path) => {
48
45
  return ts.sys.readFile(path);
@@ -51,85 +48,36 @@ export const parseTsConfig = (tsConfigPath: string = "./tsconfig.json") => {
51
48
  return ts.parseJsonConfigFileContent(configFile.config, ts.sys, realpathSync(tsConfigPath).replace(/[^/\\]+$/, ""));
52
49
  };
53
50
 
54
- /**
55
- * 파일 내의 임포트를 기반으로 관련 파일을 수집하는 함수
56
- */
57
- export const collectImportedFiles = (constantFilePath: string, parsedConfig: ts.ParsedCommandLine) => {
51
+ const collectFiles = (constantFilePath: string, parsedConfig: ts.ParsedCommandLine, includeExports: boolean) => {
58
52
  const allFilesToAnalyze = new Set<string>([constantFilePath]);
59
53
  const analyzedFiles = new Set<string>();
60
- const spinner = ora("Collecting related files...");
54
+ const spinner = ora(includeExports ? "Collecting files from exports..." : "Collecting related files...");
61
55
  spinner.start();
62
-
63
- function collectImported(filePath: string) {
56
+ const collect = (filePath: string) => {
64
57
  if (analyzedFiles.has(filePath)) return;
65
58
  analyzedFiles.add(filePath);
66
-
67
59
  const source = readFileSync(filePath, "utf-8");
68
- for (const importPath of scanModuleSpecifiers(source, filePath, false)) {
60
+ for (const importPath of scanModuleSpecifiers(source, filePath, includeExports)) {
69
61
  if (!importPath.startsWith(".")) continue;
70
-
71
62
  const resolved = ts.resolveModuleName(importPath, filePath, parsedConfig.options, ts.sys).resolvedModule
72
63
  ?.resolvedFileName;
73
-
74
64
  if (resolved && !allFilesToAnalyze.has(resolved)) {
75
65
  allFilesToAnalyze.add(resolved);
76
- // 재귀적으로 임포트된 파일의 임포트도 수집
77
- collectImported(resolved);
66
+ collect(resolved);
78
67
  }
79
68
  }
80
- }
81
-
82
- // 임포트 기반 관련 파일 수집 실행
83
- collectImported(constantFilePath);
84
- spinner.succeed(`Found ${allFilesToAnalyze.size} related files.`);
85
-
86
- return {
87
- allFilesToAnalyze,
88
- analyzedFiles,
89
69
  };
70
+ collect(constantFilePath);
71
+ spinner.succeed(`Found ${allFilesToAnalyze.size} related files${includeExports ? " from exports" : ""}.`);
72
+ return { allFilesToAnalyze, analyzedFiles };
90
73
  };
91
74
 
92
- /**
93
- * export 선언을 기반으로 관련 파일을 수집하는 함수
94
- */
95
- export const collectExportedFiles = (constantFilePath: string, parsedConfig: ts.ParsedCommandLine) => {
96
- const allFilesToAnalyze = new Set<string>([constantFilePath]);
97
- const analyzedFiles = new Set<string>();
98
- const spinner = ora("Collecting files from exports...");
99
- spinner.start();
100
-
101
- function collectExported(filePath: string) {
102
- if (analyzedFiles.has(filePath)) return;
103
- analyzedFiles.add(filePath);
75
+ export const collectImportedFiles = (constantFilePath: string, parsedConfig: ts.ParsedCommandLine) =>
76
+ collectFiles(constantFilePath, parsedConfig, false);
104
77
 
105
- const source = readFileSync(filePath, "utf-8");
106
- for (const importPath of scanModuleSpecifiers(source, filePath, true)) {
107
- if (!importPath.startsWith(".")) continue;
78
+ export const collectExportedFiles = (constantFilePath: string, parsedConfig: ts.ParsedCommandLine) =>
79
+ collectFiles(constantFilePath, parsedConfig, true);
108
80
 
109
- const resolved = ts.resolveModuleName(importPath, filePath, parsedConfig.options, ts.sys).resolvedModule
110
- ?.resolvedFileName;
111
-
112
- if (resolved && !allFilesToAnalyze.has(resolved)) {
113
- allFilesToAnalyze.add(resolved);
114
- // 재귀적으로 export/import 연결 파일도 확인
115
- collectExported(resolved);
116
- }
117
- }
118
- }
119
-
120
- // export 기반 관련 파일 수집 실행
121
- collectExported(constantFilePath);
122
- spinner.succeed(`Found ${allFilesToAnalyze.size} related files from exports.`);
123
-
124
- return {
125
- allFilesToAnalyze,
126
- analyzedFiles,
127
- };
128
- };
129
-
130
- /**
131
- * TypeScript 프로그램 생성 함수
132
- */
133
81
  export const createTsProgram = (filePaths: Set<string>, options: ts.CompilerOptions) => {
134
82
  const spinner = ora("Creating TypeScript program for all files...");
135
83
  spinner.start();
@@ -145,9 +93,6 @@ export const createTsProgram = (filePaths: Set<string>, options: ts.CompilerOpti
145
93
  };
146
94
  };
147
95
 
148
- /**
149
- * 심볼 캐싱 및 조회 함수 생성기
150
- */
151
96
  export const createSymbolCache = (checker: ts.TypeChecker) => {
152
97
  const symbolCache = new Map<string, ts.Symbol | undefined>();
153
98
 
@@ -162,9 +107,6 @@ export const createSymbolCache = (checker: ts.TypeChecker) => {
162
107
  };
163
108
  };
164
109
 
165
- /**
166
- * 파일 속성 분석 함수
167
- */
168
110
  export const analyzeProperties = (filesToAnalyze: Set<string>, program: ts.Program, checker: ts.TypeChecker) => {
169
111
  const propertyMap = new Map<
170
112
  string,
@@ -185,7 +127,6 @@ export const analyzeProperties = (filesToAnalyze: Set<string>, program: ts.Progr
185
127
  const spinner = ora("Analyzing property relationships...");
186
128
  spinner.start();
187
129
 
188
- // 파일 내 속성 분석 함수
189
130
  function analyzeFileProperties(filePath: string) {
190
131
  if (analyzedFiles.has(filePath)) return;
191
132
  analyzedFiles.add(filePath);
@@ -193,23 +134,19 @@ export const analyzeProperties = (filesToAnalyze: Set<string>, program: ts.Progr
193
134
  const source = program.getSourceFile(filePath);
194
135
  if (!source) return;
195
136
 
196
- // 소스 라인 캐싱 (한 번만 분할)
197
137
  if (!sourceLineCache.has(filePath)) {
198
138
  sourceLineCache.set(filePath, source.getFullText().split("\n"));
199
139
  }
200
140
  const sourceLines = sourceLineCache.get(filePath);
201
141
 
202
- // 속성 분석 함수
203
142
  function visit(node: ts.Node) {
204
143
  if (!source) return;
205
144
 
206
- // 프로퍼티 접근 표현식 처리
207
145
  if (ts.isPropertyAccessExpression(node)) {
208
146
  const left = node.expression;
209
147
  const right = node.name;
210
148
  const { line } = ts.getLineAndCharacterOfPosition(source, node.getStart());
211
149
 
212
- // 패턴 검색 로직 - 기존 문자열 기반 검색 유지
213
150
  if (
214
151
  ts.isIdentifier(left) &&
215
152
  sourceLines &&
@@ -219,14 +156,12 @@ export const analyzeProperties = (filesToAnalyze: Set<string>, program: ts.Progr
219
156
  (sourceLines[line]?.includes(`@Field.Prop(() => ${left.text}.${right.text}`) ||
220
157
  sourceLines[line].includes(`base.Filter(${left.text}.${right.text},`))
221
158
  ) {
222
- // 캐싱된 심볼 사용
223
159
  const symbol = getCachedSymbol(right);
224
160
 
225
161
  if (symbol?.declarations && symbol.declarations.length > 0) {
226
162
  const key = symbol.declarations[0]?.getSourceFile().fileName.split("/").pop()?.split(".")[0] ?? "";
227
163
  const property = propertyMap.get(key);
228
164
  const isScalar = symbol.declarations[0]?.getSourceFile().fileName.includes("_") ?? false;
229
- // 현재 작업 중인 디렉토리 이전은 제거
230
165
  const symbolFilePath = symbol.declarations[0]
231
166
  ?.getSourceFile()
232
167
  .fileName.replace(`${ts.sys.getCurrentDirectory()}/`, "");
@@ -254,7 +189,6 @@ export const analyzeProperties = (filesToAnalyze: Set<string>, program: ts.Progr
254
189
  }
255
190
  }
256
191
  } else if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
257
- // 모듈 선언 표현식 처리
258
192
  const importPath = node.moduleSpecifier.text;
259
193
 
260
194
  if (importPath.startsWith(".")) {
@@ -276,15 +210,12 @@ export const analyzeProperties = (filesToAnalyze: Set<string>, program: ts.Progr
276
210
  }
277
211
  }
278
212
 
279
- // 모든 자식 노드 처리
280
213
  ts.forEachChild(node, visit);
281
214
  }
282
215
 
283
- // 파일 분석 시작
284
216
  visit(source);
285
217
  }
286
218
 
287
- // 모든 파일의 속성 관계 분석
288
219
  for (const filePath of filesToAnalyze) {
289
220
  analyzeFileProperties(filePath);
290
221
  }
@@ -294,21 +225,10 @@ export const analyzeProperties = (filesToAnalyze: Set<string>, program: ts.Progr
294
225
  return propertyMap;
295
226
  };
296
227
 
297
- /**
298
- * 메인 함수: 상수 파일 관련 요소 분석
299
- */
300
228
  export const getRelatedCnsts = (constantFilePath: string) => {
301
- // 1. TypeScript 설정 파일 읽기 및 파싱
302
229
  const parsedConfig = parseTsConfig();
303
-
304
- // 2. 상수 파일 관련 파일 수집 (임포트 기반)
305
230
  const { allFilesToAnalyze } = collectImportedFiles(constantFilePath, parsedConfig);
306
- // 3. TypeScript 프로그램 생성 및 타입 체커 설정
307
231
  const { program, checker } = createTsProgram(allFilesToAnalyze, parsedConfig.options);
308
-
309
- // 4. 파일 속성 분석
310
232
  const propertyMap = analyzeProperties(allFilesToAnalyze, program, checker);
311
-
312
- // 5. 결과 반환
313
233
  return Array.from(propertyMap.entries()).map(([key, value]) => ({ key, ...value }));
314
234
  };
@@ -1,8 +1,7 @@
1
1
  import path from "node:path";
2
+ import { ApplicationBuildReporter } from "@akanjs/devkit/applicationBuildReporter";
2
3
  import type { App } from "@akanjs/devkit/commandDecorators";
3
- // Subpath imports only, and as few as possible: this process is spawned once per generation, so every
4
- // eager import is paid on every save. Measured on a 177-route app: `executors` 24ms, `frontendBuild`
5
- // ~110ms, app config 5ms.
4
+ // Subpath imports only, as few as possible: spawned per generation, this process pays every import on every save.
6
5
  import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
7
6
  import {
8
7
  CsrArtifactBuilder,
@@ -15,19 +14,7 @@ import { Logger } from "akanjs/common";
15
14
  import type { BuilderMessage, BuildPhase } from "akanjs/server";
16
15
  import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
17
16
 
18
- /**
19
- * One generation of frontend build work, in a process that exits when it is done.
20
- *
21
- * This exists for one reason: `Bun.build` retains native bundler arenas that the process never returns
22
- * to the OS — `Bun.gc(true)` reclaims nothing and the JS heap stays flat while RSS climbs ~250MB per
23
- * save. Exit is the only mechanism that gives that memory back, so the work that scales per save lives
24
- * here rather than in the long-lived watcher.
25
- *
26
- * Nothing is cached here, by design. That costs less than it appears to: `CssCompiler` rebuilds its
27
- * tailwind compilers on every `compileCss` call, and the watcher always asked for `refresh: true`, so
28
- * there was no warm state to lose. What genuinely had to be preserved travels in the request — the
29
- * validated page keys and the previous font optimization.
30
- */
17
+ // `Bun.build` keeps native bundler arenas that `Bun.gc(true)` cannot reclaim; only exiting returns them.
31
18
  class BuildBatch {
32
19
  #logger = new Logger("BuildBatch");
33
20
  #request: BuildBatchRequest;
@@ -40,29 +27,16 @@ class BuildBatch {
40
27
  }
41
28
 
42
29
  async run(): Promise<BuildBatchResult> {
43
- // `base` arrives alone, from a builder that cannot serve anything until it finishes.
44
30
  if (this.#request.needs.includes("base")) await this.#buildBase();
45
- // Ordered the way the watcher used to run them: csr before pages so a csr failure cannot delay the
46
- // pages bundle the browser is waiting on, and css last because it depends on the rebuilt client.
31
+ // Order kept from the in-process build: csr before pages, css last because it depends on the rebuilt client.
47
32
  if (this.#request.needs.includes("csr")) await this.#buildCsr();
48
33
  if (this.#request.needs.includes("pages")) await this.#buildPages();
49
34
  if (this.#request.needs.includes("css")) await this.#buildCss();
50
35
  return this.#result;
51
36
  }
52
37
 
53
- /**
54
- * Broadcast as soon as a need finishes rather than when the batch does. The browser is waiting on the
55
- * pages bundle; making it wait for the css compile behind it would add latency the in-process version
56
- * never had, and it would move every artifact write into the window right before the watcher reports
57
- * the generation complete — which is where a save issued immediately afterwards gets dropped by Bun's
58
- * recursive `fs.watch` (`local/optimize-resource/06-watcher-dropped-event.md`).
59
- *
60
- * A bare `process.send` is safe here, unlike in the watcher, for one reason: this process ends by
61
- * returning from `main`, and a natural exit flushes a pending ipc write (measured: 1MB delivered
62
- * 20/20). It is `process.exit` that discards one — so adding an explicit exit to this file, at the end
63
- * of `main` or anywhere after an emit, would silently start dropping `css-updated` payloads. Route
64
- * sends through `BuilderChannel` if that ever becomes necessary.
65
- */
38
+ // A bare `process.send` is safe only because this process exits by returning from `main`, which flushes ipc;
39
+ // an explicit `process.exit` after an emit would silently drop payloads (route through BuilderChannel then).
66
40
  #emit(message: BuilderMessage): void {
67
41
  process.send?.(message);
68
42
  }
@@ -80,11 +54,15 @@ class BuildBatch {
80
54
  });
81
55
  }
82
56
 
83
- /**
84
- * The boot build. Streams nothing: a builder is not serving yet, so there is no phase board to update
85
- * and no browser to reload — the watcher learns the outcome from the batch result, and a failure there
86
- * is what puts it into degraded watch mode.
87
- */
57
+ // `Bun.build` rejects with an AggregateError whose own message is only "Bundle failed"; the reasons are nested.
58
+ #fail(need: keyof BuildBatchResult["errors"], label: string, err: unknown): void {
59
+ const message = ApplicationBuildReporter.formatError(err, this.#request.workspaceRoot);
60
+ this.#logger.error(`${label} failed: ${message}`);
61
+ this.#result.errors[need] = message;
62
+ if (need !== "base") this.#emitStatus(need, message);
63
+ }
64
+
65
+ // Streams nothing: the builder is not serving yet, so the watcher reads the outcome from the batch result.
88
66
  async #buildBase(): Promise<void> {
89
67
  const started = Date.now();
90
68
  try {
@@ -93,9 +71,7 @@ class BuildBatch {
93
71
  this.#result.optimizedFonts = optimizedFonts;
94
72
  this.#logger.verbose(`base-artifact ok buildId=${artifact.pagesBundleBuildId} (${Date.now() - started}ms)`);
95
73
  } catch (err) {
96
- const message = err instanceof Error ? err.message : String(err);
97
- this.#logger.error(`base-artifact failed: ${message}`);
98
- this.#result.errors.base = message;
74
+ this.#fail("base", "base-artifact", err);
99
75
  }
100
76
  }
101
77
 
@@ -106,10 +82,7 @@ class BuildBatch {
106
82
  this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
107
83
  this.#emitStatus("csr");
108
84
  } catch (err) {
109
- const message = err instanceof Error ? err.message : String(err);
110
- this.#logger.error(`csr-rebundle failed: ${message}`);
111
- this.#result.errors.csr = message;
112
- this.#emitStatus("csr", message);
85
+ this.#fail("csr", "csr-rebundle", err);
113
86
  }
114
87
  }
115
88
 
@@ -129,10 +102,7 @@ class BuildBatch {
129
102
  this.#emitStatus("pages");
130
103
  this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
131
104
  } catch (err) {
132
- const message = err instanceof Error ? err.message : String(err);
133
- this.#logger.error(`pages-rebundle failed: ${message}`);
134
- this.#result.errors.pages = message;
135
- this.#emitStatus("pages", message);
105
+ this.#fail("pages", "pages-rebundle", err);
136
106
  }
137
107
  }
138
108
 
@@ -174,14 +144,10 @@ class BuildBatch {
174
144
  });
175
145
  this.#logger.verbose(`css-compile ok assets=${Object.keys(cssAssets).length} (${Date.now() - started}ms)`);
176
146
  } catch (err) {
177
- const message = err instanceof Error ? err.message : String(err);
178
- this.#logger.error(`css-rebuild failed: ${message}`);
179
- this.#result.errors.css = message;
180
- this.#emitStatus("css", message);
147
+ this.#fail("css", "css-rebuild", err);
181
148
  }
182
149
  }
183
150
 
184
- /** Fonts are expensive and rarely change, so the previous result is reused unless this batch touched it. */
185
151
  async #optimizeFonts(): Promise<OptimizedFonts> {
186
152
  const previous = this.#request.optimizedFonts;
187
153
  if (previous && !BuildBatch.#shouldReoptimizeFonts(previous, this.#request.changedFiles)) {
@@ -196,7 +162,6 @@ class BuildBatch {
196
162
  }
197
163
 
198
164
  static #shouldReoptimizeFonts(previous: OptimizedFonts, changedFiles: string[]): boolean {
199
- if (changedFiles.length === 0) return false;
200
165
  return changedFiles.some((file) => {
201
166
  const normalized = path.resolve(file);
202
167
  if (/\.(woff2?|ttf|otf)$/i.test(normalized)) return true;
@@ -213,15 +178,15 @@ class BuildBatch {
213
178
  repoName: request.repoName,
214
179
  });
215
180
  const app = AppExecutor.from(workspace, request.appName);
216
- // Seeded rather than rediscovered: the watcher already globbed and validated every route source,
217
- // and repeating that here would be the single largest cost of spawning this process.
181
+ // Seeded, not rediscovered: route discovery would be the largest cost of spawning this process.
218
182
  if (request.pageKeys) app.setPageKeys(request.pageKeys);
219
183
  const result = await new BuildBatch(request, app).run();
220
184
  process.send?.({ type: "build-batch-result", data: result });
221
185
  }
222
186
  }
223
187
 
224
- void BuildBatch.main().catch((err) => {
188
+ // Awaited, not voided: on Windows Bun exits during a missing file's `Bun.file` read unless an entry await is pending.
189
+ await BuildBatch.main().catch((err) => {
225
190
  console.error(err);
226
191
  process.exit(1);
227
192
  });