@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.91

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 (183) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +27 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +21 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +30 -14
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +1 -1
  22. package/applicationBuildRunner.ts +45 -21
  23. package/artifact/implicitRootLayout.test.ts +67 -0
  24. package/artifact/implicitRootLayout.ts +27 -7
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +86 -47
  27. package/biomeBase.ts +6 -5
  28. package/capacitorApp.ts +10 -0
  29. package/cloud/globalConfig.ts +9 -2
  30. package/codegenLock.test.ts +137 -0
  31. package/codegenLock.ts +136 -0
  32. package/commandDecorators/argMeta.ts +9 -1
  33. package/commandDecorators/command.ts +94 -7
  34. package/commandDecorators/commandBuilder.ts +20 -5
  35. package/commandDecorators/commandDecorators.test.ts +115 -3
  36. package/commandDecorators/helpFormatter.ts +4 -0
  37. package/dependencyScanner.test.ts +99 -0
  38. package/dependencyScanner.ts +27 -24
  39. package/devkitUtils.test.ts +0 -104
  40. package/executors.test.ts +129 -4
  41. package/executors.ts +105 -24
  42. package/fileEditor.ts +19 -19
  43. package/fileSys.ts +19 -1
  44. package/formSetterScanner.test.ts +80 -0
  45. package/formSetterScanner.ts +92 -0
  46. package/frontendBuild/autoImportSync.test.ts +58 -0
  47. package/frontendBuild/autoImportSync.ts +7 -2
  48. package/frontendBuild/buildRouteClient.test.ts +47 -24
  49. package/frontendBuild/clientBuildTypes.ts +4 -0
  50. package/frontendBuild/clientEntriesBundler.ts +4 -1
  51. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  52. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  53. package/frontendBuild/cssCompiler.ts +123 -12
  54. package/frontendBuild/cssImportResolver.ts +8 -7
  55. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  56. package/frontendBuild/fontOptimizer.ts +37 -18
  57. package/frontendBuild/fontPruner.test.ts +220 -0
  58. package/frontendBuild/fontPruner.ts +206 -0
  59. package/frontendBuild/frontendBuild.test.ts +178 -10
  60. package/frontendBuild/hmrWatcher.ts +1 -1
  61. package/frontendBuild/index.ts +1 -1
  62. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  63. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  64. package/frontendBuild/routeClientBuilder.ts +12 -5
  65. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  66. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  67. package/frontendBuild/styleContract.ts +14 -20
  68. package/frontendBuild/themeValidator.ts +22 -17
  69. package/frontendBuild/vendorSpecifiers.ts +1 -0
  70. package/frontendBuild/watchRootResolver.test.ts +67 -0
  71. package/frontendBuild/watchRootResolver.ts +13 -1
  72. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  73. package/incrementalBuilder/devWatchBatch.ts +6 -2
  74. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  75. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  76. package/index.ts +0 -7
  77. package/integration/devStabilityHarness.ts +2 -10
  78. package/libSource.test.ts +112 -0
  79. package/libSource.ts +126 -0
  80. package/lint/__fixtures__/README.md +40 -0
  81. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  82. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  83. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  84. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  85. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  86. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  87. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  88. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  89. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  90. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  91. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  92. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  93. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  95. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  96. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  97. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  98. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  99. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  100. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  101. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  102. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  103. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  104. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  105. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  106. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  107. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  108. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  110. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  111. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  112. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  113. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  114. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  116. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  117. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  118. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  119. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  120. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  121. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  122. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  123. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  124. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  125. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  126. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  127. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  128. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  129. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  130. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  131. package/lint/gritRules.test.ts +178 -0
  132. package/lint/no-arbitrary-color.grit +1 -1
  133. package/lint/no-async-component-in-ui.grit +35 -0
  134. package/lint/no-bang-comment-in-client.grit +23 -10
  135. package/lint/no-daisyui-legacy-class.grit +26 -9
  136. package/lint/no-deprecated-log-level.grit +17 -0
  137. package/lint/no-init-fetch-in-client.grit +47 -0
  138. package/lint/no-inline-color.grit +10 -8
  139. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  140. package/lint/no-model-type-in-util-zone.grit +58 -0
  141. package/lint/no-raw-palette-class.grit +3 -3
  142. package/lint/no-unpublished-form-setter.grit +41 -0
  143. package/lint/non-scalar-props-restricted.grit +16 -7
  144. package/linter.test.ts +80 -0
  145. package/linter.ts +99 -25
  146. package/package.json +5 -8
  147. package/prompter.ts +9 -4
  148. package/qualityScanner.test.ts +116 -0
  149. package/qualityScanner.ts +89 -21
  150. package/recipeScanner.ts +4 -1
  151. package/scanInfo.ts +32 -26
  152. package/semver.test.ts +26 -0
  153. package/semver.ts +31 -0
  154. package/slicePlanner.test.ts +182 -0
  155. package/slicePlanner.ts +235 -0
  156. package/ssrScanner.test.ts +301 -0
  157. package/subspace.test.ts +418 -0
  158. package/subspace.ts +723 -0
  159. package/subspaceConfig.ts +76 -0
  160. package/transforms/asyncDefaultExportDetector.ts +103 -0
  161. package/transforms/barrelImportsPlugin.ts +1 -1
  162. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  163. package/transforms/transforms.test.ts +5 -5
  164. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  165. package/tsconfig.json +6 -2
  166. package/typeChecker.ts +1 -1
  167. package/types.ts +1 -0
  168. package/uploadRelease.ts +2 -2
  169. package/workflow/executor.test.ts +146 -0
  170. package/workspaceLayout.test.ts +56 -4
  171. package/workspaceLayout.ts +48 -2
  172. package/builder.ts +0 -164
  173. package/extractDeps.ts +0 -86
  174. package/frontendBuild/styleGuard.test.ts +0 -165
  175. package/frontendBuild/styleGuard.ts +0 -322
  176. package/getCredentials.ts +0 -19
  177. package/getModelFileData.ts +0 -62
  178. package/src/capacitorApp.ts +0 -282
  179. package/streamAi.ts +0 -45
  180. package/ui/MultiScrollList.tsx +0 -242
  181. package/ui/ScrollList.tsx +0 -107
  182. package/ui/index.ts +0 -2
  183. package/useStdoutDimensions.ts +0 -20
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 2.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [6d58c7e]
8
+ - Updated dependencies [11aa655]
9
+ - Updated dependencies [25d5b15]
10
+ - Updated dependencies [11aa655]
11
+ - Updated dependencies [11aa655]
12
+ - Updated dependencies [25d5b15]
13
+ - Updated dependencies [42cf7a2]
14
+ - Updated dependencies [25d5b15]
15
+ - Updated dependencies [04cb46d]
16
+ - akanjs@2.4.2
17
+
3
18
  ## 2.4.1
4
19
 
5
20
  ### Patch Changes
@@ -77,6 +77,33 @@ which is the one case where replacing it again cannot help; it says that too, an
77
77
  | `AKAN_MEMORY_LOG_INTERVAL_MS` | `60000` | How often that report is written. |
78
78
  | `AKAN_MEMORY_GC_ON_REPORT` | off | `=1` forces a GC before each report, so the number is retained memory rather than garbage. Costs a full GC per report. |
79
79
 
80
+ ## Several apps at once
81
+
82
+ `akan start a,b` runs one dev host per app under a supervisor, so **every number above multiplies by the number
83
+ of apps** — there is no shared builder and no shared RSC worker. Measured on this repo, right after both apps
84
+ finished booting:
85
+
86
+ | process | akan | minimal |
87
+ |---|---|---|
88
+ | dev host | 101MB | 94MB |
89
+ | incremental builder | 596MB | 532MB |
90
+ | backend | 35MB | 38MB |
91
+ | RSC worker | 190MB | 81MB |
92
+
93
+ Plus ~51MB for the supervisor itself: **~1.7GB for two apps at their peak.** The builders are almost all of
94
+ that, and they are also the part that goes away — `AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so
95
+ a session where you are editing one app settles to roughly one builder plus ~190MB per idle app. **Multi-app is
96
+ sized against idle suspend being on**; setting it to `0` keeps every builder resident for the whole session.
97
+
98
+ Two things bound the peak rather than the floor:
99
+
100
+ - **`--concurrency` (default 1).** Apps boot in waves, and the next wave starts only once the previous one
101
+ reports ready. A cold boot build is the builder's peak, so booting `n` apps at once means `n` overlapping
102
+ peaks — which is what OOM-kills a container that would have been fine with them staggered.
103
+ - **`AKAN_MEMORY_LIMIT` is per process, not per session.** Each dev host derives its builder and RSC-worker
104
+ ceilings from it independently, so a limit sized for one app does not become a budget for four. Divide it
105
+ yourself, or leave it unset on a laptop.
106
+
80
107
  ## Sizing a small sandbox
81
108
 
82
109
  A worked example, for a 1.2GB container:
package/README.ko.md CHANGED
@@ -56,7 +56,7 @@ await runner.build();
56
56
 
57
57
  ## 요구사항
58
58
 
59
- - [Bun](https://bun.sh) `>=1.3.13`
59
+ - [Bun](https://bun.sh) `>=1.4.0`
60
60
  - TypeScript
61
61
  - Optional peer는 Capacitor integration처럼 해당 기능을 사용할 때만 필요합니다.
62
62
 
package/README.md CHANGED
@@ -60,7 +60,7 @@ defaults, the shares they derive from `AKAN_MEMORY_LIMIT`, and what a small cont
60
60
 
61
61
  ## Requirements
62
62
 
63
- - [Bun](https://bun.sh) `>=1.3.13`
63
+ - [Bun](https://bun.sh) `>=1.4.0`
64
64
  - TypeScript
65
65
  - Optional peers are only needed for the features that use them, such as Capacitor integration.
66
66
 
@@ -10,6 +10,7 @@ import {
10
10
  renderRecipeEntries,
11
11
  renderScopeAgentBlock,
12
12
  renderScopeAgentsMd,
13
+ renderScopeClaudeMd,
13
14
  upsertAgentBlock,
14
15
  } from "./agentsIndex";
15
16
  import type { RecipeInfo } from "./recipeScanner";
@@ -48,6 +49,15 @@ describe("renderScopeAgentBlock", () => {
48
49
  });
49
50
  });
50
51
 
52
+ describe("renderScopeClaudeMd", () => {
53
+ test("points at AGENTS.md and restates only the comment rule", () => {
54
+ const claude = renderScopeClaudeMd({ type: "app", name: "minimal" });
55
+ expect(claude).toContain("@AGENTS.md");
56
+ expect(claude).toContain("## Comments — Overrides Your Default");
57
+ expect(claude).not.toContain("## Recipes In Scope");
58
+ });
59
+ });
60
+
51
61
  describe("upsertAgentBlock / extractAgentBlock", () => {
52
62
  test("round-trips: fresh file → replace block → extract equals block", () => {
53
63
  const fresh = renderScopeAgentsMd({ type: "app", name: "minimal" }, "OLD");
package/agentsIndex.ts CHANGED
@@ -165,8 +165,28 @@ ${block}
165
165
  ${AGENT_BLOCK_END}
166
166
  `;
167
167
 
168
- /** Claude Code reads nested CLAUDE.md files as it works under a directory; keep it a thin pointer. */
168
+ export const CLAUDE_COMMENT_RULE = `## Comments Overrides Your Default
169
+
170
+ Write **no comments** unless the comment passes the test below. This is the rule agents break most often here, so it
171
+ is repeated outside the guide: a diff that adds a comment the test rejects is a diff to redo.
172
+
173
+ Before typing \`//\`, \`/*\`, or a doc block, ask — **does this sentence carry a fact that is nowhere in the code?**
174
+
175
+ - Restates the identifier, the signature, or the line under it → delete it.
176
+ - Labels a section (\`// helpers\`, \`// state\`) or narrates a step (\`// fetch the user\`, \`// then save\`) → delete it.
177
+ - JSDoc on an ordinary function, or a why/how preamble on ordinary logic → delete it.
178
+ - Explains the edit you just made, for whoever reads the diff → say it in your reply, not in the file.
179
+ - Names a vendor or protocol quirk, an infrastructure constraint, a library gotcha, security reasoning, a math
180
+ derivation, a domain field's business meaning, a state transition, or why an obvious alternative was rejected →
181
+ keep it, one line.
182
+
183
+ That keep-list is exact — \`Comments\` in the guide is the full version. "It aids readability" and "this logic is
184
+ subtle" are not on it: rename or split the code instead. When you edit an existing file, match its density; if the
185
+ surrounding code carries none, your diff carries none.`;
186
+
169
187
  export const renderScopeClaudeMd = (scope: AgentsIndexScope): string => `# ${scope.name} — Claude Code Guide
170
188
 
171
189
  @AGENTS.md
190
+
191
+ ${CLAUDE_COMMENT_RULE}
172
192
  `;
package/aiEditor.ts CHANGED
@@ -19,7 +19,7 @@ import type { FileContent } from "./types";
19
19
 
20
20
  const MAX_ASK_TRY = 300;
21
21
 
22
- const deepSeekLlmModels = ["deepseek-chat", "deepseek-reasoner"] as const;
22
+ const deepSeekLlmModels = ["deepseek-v4-flash", "deepseek-v4-pro"] as const;
23
23
 
24
24
  const openAiLlmModels = ["gpt-5.5"] as const;
25
25
 
@@ -0,0 +1,120 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Logger } from "akanjs/common";
6
+ import type { App } from "../commandDecorators";
7
+ import { BackendImportGraph } from "./BackendImportGraph";
8
+ import { filesChangedSince } from "./devHostPolicy";
9
+
10
+ describe("BackendImportGraph", () => {
11
+ const tempRoots: string[] = [];
12
+
13
+ const makeGraph = async (files: Record<string, string>) => {
14
+ // Realpath, not the mkdtemp path: `Bun.resolveSync` returns real paths, and on macOS `/var/folders`
15
+ // is a symlink, so an unresolved root makes every resolved import look like it escapes the workspace.
16
+ const workspaceRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "akan-devkit-graph-")));
17
+ tempRoots.push(workspaceRoot);
18
+ const cwdPath = path.join(workspaceRoot, "apps/demo");
19
+ for (const [rel, source] of Object.entries(files)) {
20
+ const filePath = path.join(cwdPath, rel);
21
+ await mkdir(path.dirname(filePath), { recursive: true });
22
+ await writeFile(filePath, source);
23
+ }
24
+ const app = { cwdPath, workspace: { workspaceRoot } } as unknown as App;
25
+ return { graph: new BackendImportGraph(app, new Logger("test")), cwdPath };
26
+ };
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
30
+ });
31
+
32
+ test("walks the backend entrypoints' import graph", async () => {
33
+ const { graph, cwdPath } = await makeGraph({
34
+ "main.ts": 'import "./server";\n',
35
+ "server.ts": 'import { handler } from "./lib/handler";\nexport default handler;\n',
36
+ "lib/handler.ts": "export const handler = () => null;\n",
37
+ "lib/unreachable.ts": "export const nope = 1;\n",
38
+ });
39
+
40
+ expect(await graph.refresh()).toBe(true);
41
+ expect(graph.has(path.join(cwdPath, "lib/handler.ts"))).toBe(true);
42
+ expect(graph.has(path.join(cwdPath, "lib/unreachable.ts"))).toBe(false);
43
+ });
44
+
45
+ test("picks up an import added to an already-scanned file", async () => {
46
+ const { graph, cwdPath } = await makeGraph({
47
+ "main.ts": 'import "./server";\n',
48
+ "server.ts": "export default 1;\n",
49
+ "lib/added.ts": "export const added = 1;\n",
50
+ });
51
+ await graph.refresh();
52
+ expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(false);
53
+
54
+ // The scan cache is keyed on (mtimeMs, size), so the rewrite must invalidate it.
55
+ await writeFile(path.join(cwdPath, "server.ts"), 'import "./lib/added";\nexport default 1;\n');
56
+
57
+ await graph.refresh();
58
+ expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(true);
59
+ });
60
+
61
+ test("drops a file that left the graph", async () => {
62
+ const { graph, cwdPath } = await makeGraph({
63
+ "main.ts": 'import "./server";\n',
64
+ "server.ts": 'import "./lib/leaving";\nexport default 1;\n',
65
+ "lib/leaving.ts": "export const leaving = 1;\n",
66
+ });
67
+ await graph.refresh();
68
+ expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(true);
69
+
70
+ await writeFile(path.join(cwdPath, "server.ts"), "export default 1;\n");
71
+ await graph.refresh();
72
+ expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(false);
73
+ });
74
+
75
+ test("reports which backend files moved while nobody was watching", async () => {
76
+ const { graph, cwdPath } = await makeGraph({
77
+ "main.ts": 'import "./server";\n',
78
+ "server.ts": 'import "./lib/handler";\nexport default 1;\n',
79
+ "lib/handler.ts": "export const handler = () => null;\n",
80
+ });
81
+ await graph.refresh();
82
+ const before = await graph.fingerprint();
83
+
84
+ // The builder is gone here, so no watcher event exists for this save — which is the whole reason
85
+ // the stamps are taken. `mtimeMs` has a coarse clock on Linux, so the size has to move too.
86
+ await writeFile(path.join(cwdPath, "lib/handler.ts"), "export const handler = () => 'changed';\n");
87
+
88
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([path.join(cwdPath, "lib/handler.ts")]);
89
+ });
90
+
91
+ test("says nothing when the tree is untouched, and names a deleted file", async () => {
92
+ const { graph, cwdPath } = await makeGraph({
93
+ "main.ts": 'import "./server";\n',
94
+ "server.ts": 'import "./lib/handler";\nexport default 1;\n',
95
+ "lib/handler.ts": "export const handler = () => null;\n",
96
+ });
97
+ await graph.refresh();
98
+ const before = await graph.fingerprint();
99
+ // A recycle with no edit in it is the common case, and it must not cost a backend restart.
100
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([]);
101
+
102
+ await rm(path.join(cwdPath, "lib/handler.ts"));
103
+ // Deleted counts as changed: the backend is still running what used to be there.
104
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([path.join(cwdPath, "lib/handler.ts")]);
105
+ });
106
+
107
+ test("keeps the previous graph when a refresh finds no entrypoints", async () => {
108
+ const { graph, cwdPath } = await makeGraph({
109
+ "main.ts": 'import "./lib/kept";\n',
110
+ "lib/kept.ts": "export const kept = 1;\n",
111
+ });
112
+ await graph.refresh();
113
+ expect(graph.ready).toBe(true);
114
+
115
+ await rm(path.join(cwdPath, "main.ts"));
116
+ await graph.refresh();
117
+ // An empty scan is not a failure, so the graph legitimately empties out.
118
+ expect(graph.has(path.join(cwdPath, "lib/kept.ts"))).toBe(false);
119
+ });
120
+ });
@@ -0,0 +1,167 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Logger } from "akanjs/common";
4
+ import type { App } from "../commandDecorators";
5
+ import { SOURCE_EXTS, type SourceFingerprints } from "./devHostPolicy";
6
+
7
+ const NON_SOURCE_EXT_RE =
8
+ /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
9
+
10
+ const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
11
+ "import-statement",
12
+ "require-call",
13
+ "require-resolve",
14
+ "dynamic-import",
15
+ ]);
16
+
17
+ export class BackendImportGraph {
18
+ readonly #app: App;
19
+ readonly #logger: Logger;
20
+ readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
21
+ readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
22
+ readonly #jsTranspiler = new Bun.Transpiler({ loader: "js" });
23
+ readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
24
+ #files = new Set<string>();
25
+ /**
26
+ * `refresh()` runs on every server-side save and on every dev-host recycle, and re-reading plus
27
+ * re-parsing files that did not change is the whole cost of it. Keyed on (mtimeMs, size).
28
+ *
29
+ * Specifiers are cached, not resolved paths: creating a file can change what an *unchanged* importer's
30
+ * specifier resolves to, and `Bun.resolveSync` is cheap next to a read plus a transpiler scan. Only
31
+ * the scan result is retained — never the source text.
32
+ */
33
+ #scanCache = new Map<string, { mtimeMs: number; size: number; specifiers: Bun.Import[] }>();
34
+ #ready = false;
35
+ #lastRefreshSucceeded = false;
36
+
37
+ constructor(app: App, logger: Logger) {
38
+ this.#app = app;
39
+ this.#logger = logger;
40
+ }
41
+
42
+ get ready() {
43
+ return this.#ready;
44
+ }
45
+
46
+ get lastRefreshSucceeded() {
47
+ return this.#lastRefreshSucceeded;
48
+ }
49
+
50
+ has(file: string) {
51
+ return this.#files.has(path.resolve(file));
52
+ }
53
+
54
+ /**
55
+ * Stamp every file the backend runs, so a caller can ask later what moved.
56
+ *
57
+ * Taken when the builder goes away and compared when its replacement is up, because nothing watches
58
+ * the tree in between: the departing builder's watcher left with it, and the replacement's index
59
+ * primes from the disk it finds, so an edit that lands in the gap is *baseline* to it and is never
60
+ * reported at all. The client half of such an edit is rescued by the replacement's boot build; the
61
+ * backend half is a server left running code that no longer exists, with nothing on screen to say so.
62
+ *
63
+ * One `stat` per graph file, against a gap that costs a whole boot build anyway.
64
+ */
65
+ async fingerprint(): Promise<SourceFingerprints> {
66
+ const stamps = await Promise.all(
67
+ [...this.#files].map(async (file) => {
68
+ const stats = await stat(file).catch(() => null);
69
+ return [file, stats ? `${Math.round(stats.mtimeMs)}:${stats.size}` : "(gone)"] as const;
70
+ }),
71
+ );
72
+ return new Map(stamps);
73
+ }
74
+
75
+ async refresh(): Promise<boolean> {
76
+ try {
77
+ const files = await this.#build();
78
+ this.#files = files;
79
+ this.#ready = true;
80
+ this.#lastRefreshSucceeded = true;
81
+ this.#logger.verbose(`[backend-graph] scanned ${files.size} files`);
82
+ return true;
83
+ } catch (err) {
84
+ this.#ready = this.#files.size > 0;
85
+ this.#lastRefreshSucceeded = false;
86
+ this.#logger.warn(
87
+ `[backend-graph] scan failed; ${this.#ready ? "using previous graph" : "using fallback rules"}: ${err instanceof Error ? err.message : String(err)}`,
88
+ );
89
+ return this.#ready;
90
+ }
91
+ }
92
+
93
+ async #build(): Promise<Set<string>> {
94
+ const roots = await this.#entrypoints();
95
+ const files = new Set<string>();
96
+ const queue = [...roots];
97
+ const workspaceRoot = path.resolve(this.#app.workspace.workspaceRoot);
98
+
99
+ while (queue.length > 0) {
100
+ const current = path.resolve(queue.pop() as string);
101
+ if (files.has(current)) continue;
102
+ if (!this.#isWorkspaceSource(current, workspaceRoot)) continue;
103
+ const imports = await this.#importsOf(current);
104
+ if (!imports) continue;
105
+
106
+ files.add(current);
107
+ const importerDir = path.dirname(current);
108
+ for (const imp of imports) {
109
+ if (!GRAPH_IMPORT_KINDS.has(imp.kind) || !imp.path || NON_SOURCE_EXT_RE.test(imp.path)) continue;
110
+ const resolved = this.#resolve(imp.path, importerDir);
111
+ if (!resolved || files.has(resolved)) continue;
112
+ queue.push(resolved);
113
+ }
114
+ }
115
+ // Files that dropped out of the graph would otherwise be cached for the life of the dev session.
116
+ for (const cached of this.#scanCache.keys()) if (!files.has(cached)) this.#scanCache.delete(cached);
117
+ return files;
118
+ }
119
+
120
+ /** Null when the file is gone, which is the existence check the walk used to make separately. */
121
+ async #importsOf(file: string): Promise<Bun.Import[] | null> {
122
+ const stats = await stat(file).catch(() => null);
123
+ if (!stats?.isFile()) return null;
124
+ const mtimeMs = Math.round(stats.mtimeMs);
125
+ const cached = this.#scanCache.get(file);
126
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === stats.size) return cached.specifiers;
127
+ const specifiers = this.#scanImports(file, await Bun.file(file).text());
128
+ this.#scanCache.set(file, { mtimeMs, size: stats.size, specifiers });
129
+ return specifiers;
130
+ }
131
+
132
+ async #entrypoints(): Promise<string[]> {
133
+ const roots = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
134
+ const existing: string[] = [];
135
+ for (const root of roots) {
136
+ const abs = path.resolve(root);
137
+ if (await Bun.file(abs).exists()) existing.push(abs);
138
+ }
139
+ return existing;
140
+ }
141
+
142
+ #resolve(specifier: string, importerDir: string): string | null {
143
+ try {
144
+ const resolved = Bun.resolveSync(specifier, importerDir);
145
+ if (!path.isAbsolute(resolved)) return null;
146
+ if (!SOURCE_EXTS.has(path.extname(resolved).toLowerCase())) return null;
147
+ return path.resolve(resolved);
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ #isWorkspaceSource(file: string, workspaceRoot: string): boolean {
154
+ const rel = path.relative(workspaceRoot, file);
155
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
156
+ if (rel.includes(`${path.sep}node_modules${path.sep}`) || rel.includes(`${path.sep}.akan${path.sep}`)) return false;
157
+ return SOURCE_EXTS.has(path.extname(file).toLowerCase());
158
+ }
159
+
160
+ #scanImports(file: string, source: string): Bun.Import[] {
161
+ const ext = path.extname(file).toLowerCase();
162
+ if (ext === ".tsx") return this.#tsxTranspiler.scanImports(source);
163
+ if (ext === ".jsx") return this.#jsxTranspiler.scanImports(source);
164
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return this.#jsTranspiler.scanImports(source);
165
+ return this.#tsTranspiler.scanImports(source);
166
+ }
167
+ }