@akanjs/devkit 3.0.0-beta.9 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -1,17 +1,9 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { chmod, mkdir, mkdtemp, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { chmod, mkdir, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
5
5
  import { SourceMtimeIndex } from "./sourceMtimeIndex";
6
6
 
7
- const roots: string[] = [];
8
-
9
- const makeRoot = async () => {
10
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-mtime-index-"));
11
- roots.push(root);
12
- return root;
13
- };
14
-
15
7
  const seed = async (root: string, rel: string, content = "export const x = 1;\n") => {
16
8
  const abs = path.join(root, rel);
17
9
  await mkdir(path.dirname(abs), { recursive: true });
@@ -19,17 +11,16 @@ const seed = async (root: string, rel: string, content = "export const x = 1;\n"
19
11
  return abs;
20
12
  };
21
13
 
22
- /**
23
- * mtime comparison needs the write to land on a different timestamp than the baseline. APFS records
24
- * nanoseconds so same-millisecond writes normally still differ, but size is compared too and a
25
- * same-length rewrite inside one tick would tie both — so tests vary content length rather than sleep.
26
- */
14
+ const primed = async (indexRoots: string[], dirSettleMs?: number) => {
15
+ const index = new SourceMtimeIndex({ roots: indexRoots, dirSettleMs });
16
+ await index.prime();
17
+ return index;
18
+ };
19
+
20
+ // Varies content length rather than sleeping: a same-length rewrite inside one mtime tick would tie both mtime and size.
27
21
  const rewrite = (abs: string, marker: string) => writeFile(abs, `export const x = ${marker};\n`);
28
22
 
29
- /**
30
- * `rm` cannot traverse a directory a test left unreadable, and a throw here fails the *next* test rather
31
- * than the one that caused it — so permissions are restored on the way down before giving up.
32
- */
23
+ // `rm` cannot traverse a directory a test left unreadable, and a throw here fails the next test, so restore permissions.
33
24
  const forceRemove = async (target: string): Promise<void> => {
34
25
  await rm(target, { recursive: true, force: true }).catch(async () => {
35
26
  await chmod(target, 0o755).catch(() => undefined);
@@ -39,16 +30,13 @@ const forceRemove = async (target: string): Promise<void> => {
39
30
  });
40
31
  };
41
32
 
42
- afterEach(async () => {
43
- await Promise.all(roots.splice(0).map((root) => forceRemove(root)));
44
- });
33
+ const makeRoot = tempDirs("akan-mtime-index-", forceRemove);
45
34
 
46
35
  describe("SourceMtimeIndex", () => {
47
36
  test("reports nothing on a quiet tree", async () => {
48
37
  const root = await makeRoot();
49
38
  await seed(root, "lib/a.ts");
50
- const index = new SourceMtimeIndex({ roots: [root] });
51
- await index.prime();
39
+ const index = await primed([root]);
52
40
 
53
41
  expect(index.trackedFileCount).toBe(1);
54
42
  expect(await index.collectChanges()).toEqual([]);
@@ -58,10 +46,8 @@ describe("SourceMtimeIndex", () => {
58
46
  test("reports every file of a save-all, which is what fs.watch loses", async () => {
59
47
  const root = await makeRoot();
60
48
  const files = await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
61
- const index = new SourceMtimeIndex({ roots: [root] });
62
- await index.prime();
49
+ const index = await primed([root]);
63
50
 
64
- // No gaps: exactly the burst Bun collapses to a single reported path.
65
51
  for (const [i, abs] of files.entries()) await rewrite(abs, `${i}00`);
66
52
 
67
53
  expect((await index.collectChanges()).sort()).toEqual([...files].sort());
@@ -70,8 +56,7 @@ describe("SourceMtimeIndex", () => {
70
56
  test("reports a change once, then stops reporting it", async () => {
71
57
  const root = await makeRoot();
72
58
  const abs = await seed(root, "lib/a.ts");
73
- const index = new SourceMtimeIndex({ roots: [root] });
74
- await index.prime();
59
+ const index = await primed([root]);
75
60
 
76
61
  await rewrite(abs, "222");
77
62
  expect(await index.collectChanges()).toEqual([abs]);
@@ -81,8 +66,7 @@ describe("SourceMtimeIndex", () => {
81
66
  test("reports a created file, found through its directory's mtime", async () => {
82
67
  const root = await makeRoot();
83
68
  await seed(root, "lib/a.ts");
84
- const index = new SourceMtimeIndex({ roots: [root] });
85
- await index.prime();
69
+ const index = await primed([root]);
86
70
 
87
71
  const created = await seed(root, "lib/b.ts");
88
72
  expect(await index.collectChanges()).toEqual([created]);
@@ -92,34 +76,23 @@ describe("SourceMtimeIndex", () => {
92
76
  test("reports a created directory's files", async () => {
93
77
  const root = await makeRoot();
94
78
  await seed(root, "lib/a.ts");
95
- const index = new SourceMtimeIndex({ roots: [root] });
96
- await index.prime();
79
+ const index = await primed([root]);
97
80
 
98
81
  const created = await seed(root, "lib/user/user.constant.ts");
99
82
  expect(await index.collectChanges()).toEqual([created]);
100
83
  expect(await index.collectChanges()).toEqual([]);
101
84
  });
102
85
 
103
- /**
104
- * Linux stamps directory mtimes from a coarse clock, so a mutation landing in the same tick as the
105
- * value the index recorded leaves that value byte-identical: measured under Docker, 319 of 400
106
- * back-to-back `mkdir`s never moved the parent's mtime on overlayfs and 324 of 400 on ext4, while APFS
107
- * missed none. `utimes` reproduces that here rather than leaving it to the host's clock resolution —
108
- * otherwise this passes on macOS for the wrong reason and is flaky on the Linux fleet.
109
- *
110
- * `dirSettleMs` is pinned wide so the assertion is about the mechanism, not about how many milliseconds
111
- * the lines above happened to take.
112
- */
86
+ // `utimes` pins the parent's mtime to reproduce Linux's coarse directory clock on any host; `dirSettleMs` is
87
+ // pinned wide so the assertion does not depend on how long the lines above took.
113
88
  test("finds a created directory even when the clock never moves the parent's mtime", async () => {
114
89
  const root = await makeRoot();
115
90
  await seed(root, "lib/a.ts");
116
91
  const dir = path.join(root, "lib");
117
- // Pinned before priming too: `utimes` keeps whole milliseconds but drops APFS's sub-millisecond part,
118
- // so stamping both sides is what makes "the mtime did not move" exact rather than 0.5ms apart.
92
+ // Stamped before priming too: `utimes` drops APFS's sub-millisecond part, so both sides must be whole ms.
119
93
  const frozen = new Date();
120
94
  await utimes(dir, frozen, frozen);
121
- const index = new SourceMtimeIndex({ roots: [root], dirSettleMs: 60_000 });
122
- await index.prime();
95
+ const index = await primed([root], 60_000);
123
96
 
124
97
  const created = await seed(root, "lib/user/user.constant.ts");
125
98
  await utimes(dir, frozen, frozen);
@@ -132,12 +105,9 @@ describe("SourceMtimeIndex", () => {
132
105
  test("stops re-reading a directory once its mtime is old enough to trust", async () => {
133
106
  const root = await makeRoot();
134
107
  await seed(root, "lib/a.ts");
135
- const index = new SourceMtimeIndex({ roots: [root], dirSettleMs: 60_000 });
136
- await index.prime();
108
+ const index = await primed([root], 60_000);
137
109
  expect(index.hasUnsettledDirs).toBe(true);
138
110
 
139
- // The retry compensates for a timestamp that is too fresh to trust; it must not become a standing
140
- // full walk once the tree settles.
141
111
  const settled = new Date(Date.now() - 120_000);
142
112
  for (const dir of [root, path.join(root, "lib")]) await utimes(dir, settled, settled);
143
113
 
@@ -149,8 +119,7 @@ describe("SourceMtimeIndex", () => {
149
119
  const root = await makeRoot();
150
120
  const kept = await seed(root, "lib/a.ts");
151
121
  const removed = await seed(root, "lib/gone/b.ts");
152
- const index = new SourceMtimeIndex({ roots: [root] });
153
- await index.prime();
122
+ const index = await primed([root]);
154
123
 
155
124
  await rm(path.join(root, "lib/gone"), { recursive: true, force: true });
156
125
  expect(await index.collectChanges()).toEqual([removed]);
@@ -161,8 +130,7 @@ describe("SourceMtimeIndex", () => {
161
130
  test("ignores build output and node_modules", async () => {
162
131
  const root = await makeRoot();
163
132
  await seed(root, "lib/a.ts");
164
- const index = new SourceMtimeIndex({ roots: [root] });
165
- await index.prime();
133
+ const index = await primed([root]);
166
134
  expect(index.trackedFileCount).toBe(1);
167
135
 
168
136
  await seed(root, ".akan/artifact/server/pages-1.js");
@@ -174,8 +142,7 @@ describe("SourceMtimeIndex", () => {
174
142
  test("ignores files no classifier kind applies to", async () => {
175
143
  const root = await makeRoot();
176
144
  await seed(root, "lib/a.ts");
177
- const index = new SourceMtimeIndex({ roots: [root] });
178
- await index.prime();
145
+ const index = await primed([root]);
179
146
 
180
147
  await seed(root, "public/logo.svg", "<svg/>");
181
148
  expect(await index.collectChanges()).toEqual([]);
@@ -184,8 +151,7 @@ describe("SourceMtimeIndex", () => {
184
151
  test("absorb adopts a write instead of reporting it", async () => {
185
152
  const root = await makeRoot();
186
153
  const abs = await seed(root, "lib/index.ts");
187
- const index = new SourceMtimeIndex({ roots: [root] });
188
- await index.prime();
154
+ const index = await primed([root]);
189
155
 
190
156
  await rewrite(abs, "333");
191
157
  await index.absorb([abs]);
@@ -195,8 +161,7 @@ describe("SourceMtimeIndex", () => {
195
161
  test("absorb of an unknown path does not start tracking a change", async () => {
196
162
  const root = await makeRoot();
197
163
  await seed(root, "lib/a.ts");
198
- const index = new SourceMtimeIndex({ roots: [root] });
199
- await index.prime();
164
+ const index = await primed([root]);
200
165
 
201
166
  const created = await seed(root, "lib/generated.ts");
202
167
  await index.absorb([created]);
@@ -206,10 +171,8 @@ describe("SourceMtimeIndex", () => {
206
171
  test("counts a file once when a root is nested inside another root", async () => {
207
172
  const root = await makeRoot();
208
173
  await seed(root, "page/_index.tsx");
209
- const nested = new SourceMtimeIndex({ roots: [root, path.join(root, "page")] });
210
- await nested.prime();
211
- const flat = new SourceMtimeIndex({ roots: [root] });
212
- await flat.prime();
174
+ const nested = await primed([root, path.join(root, "page")]);
175
+ const flat = await primed([root]);
213
176
 
214
177
  expect(nested.trackedFileCount).toBe(flat.trackedFileCount);
215
178
  });
@@ -217,11 +180,8 @@ describe("SourceMtimeIndex", () => {
217
180
  test("concurrent scans do not invent a change", async () => {
218
181
  const root = await makeRoot();
219
182
  await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
220
- const index = new SourceMtimeIndex({ roots: [root] });
221
- await index.prime();
183
+ const index = await primed([root]);
222
184
 
223
- // Overlapping scans used to corrupt each other's view of the baseline: one pruned an entry the other
224
- // had already snapshotted, and the path came back as a change nothing had written.
225
185
  const rounds = await Promise.all([1, 2, 3, 4].map(() => index.collectChanges()));
226
186
  expect(rounds.flat()).toEqual([]);
227
187
  });
@@ -229,8 +189,7 @@ describe("SourceMtimeIndex", () => {
229
189
  test("concurrent scans report a real change exactly once between them", async () => {
230
190
  const root = await makeRoot();
231
191
  const abs = await seed(root, "lib/a.ts");
232
- const index = new SourceMtimeIndex({ roots: [root] });
233
- await index.prime();
192
+ const index = await primed([root]);
234
193
 
235
194
  await rewrite(abs, "444");
236
195
  const rounds = await Promise.all([1, 2, 3].map(() => index.collectChanges()));
@@ -247,63 +206,49 @@ describe("SourceMtimeIndex", () => {
247
206
  });
248
207
 
249
208
  describe("a directory it cannot read", () => {
250
- /**
251
- * `chmod 000` does not stop root, so these would assert the opposite of what they mean when the suite
252
- * runs as root (CI containers commonly do).
253
- */
254
- const asRoot = process.getuid?.() === 0;
209
+ // `chmod 000` does not stop root (CI containers often run as root), and on Windows it leaves a directory listable.
210
+ const cannotLock = process.getuid?.() === 0 || process.platform === "win32";
255
211
 
256
- test.skipIf(asRoot)("is reported as a gap instead of silently skipped", async () => {
212
+ test.skipIf(cannotLock)("is reported as a gap instead of silently skipped", async () => {
257
213
  const root = await makeRoot();
258
214
  await seed(root, "open/a.ts");
259
215
  const hidden = await seed(root, "locked/b.ts");
260
216
  await chmod(path.dirname(hidden), 0o000);
261
217
 
262
- const index = new SourceMtimeIndex({ roots: [root] });
263
- await index.prime();
218
+ const index = await primed([root]);
264
219
 
265
- // Priming still succeeds — it just cannot see everything, and that is the part that must be said out
266
- // loud rather than inferred from a file count nobody is checking.
267
220
  expect(index.primed).toBe(true);
268
221
  expect(index.trackedFileCount).toBe(1);
269
222
  expect(index.coverageGaps).toEqual([{ path: path.dirname(hidden), code: "EACCES" }]);
270
223
  });
271
224
 
272
- test.skipIf(asRoot)("becomes visible again once it is readable, and clears the gap", async () => {
225
+ test.skipIf(cannotLock)("becomes visible again once it is readable, and clears the gap", async () => {
273
226
  const root = await makeRoot();
274
227
  const hidden = await seed(root, "locked/b.ts");
275
228
  const locked = path.dirname(hidden);
276
229
  await chmod(locked, 0o000);
277
- const index = new SourceMtimeIndex({ roots: [root] });
278
- await index.prime();
230
+ const index = await primed([root]);
279
231
 
280
232
  await chmod(locked, 0o755);
281
233
 
282
- // Before the fix this stayed empty forever: the directory had been forgotten, so nothing re-stat'd
283
- // it, and its parent's mtime never moves when a child merely becomes readable again.
284
234
  expect(await index.collectChanges()).toEqual([hidden]);
285
235
  expect(index.coverageGaps).toEqual([]);
286
236
  await rewrite(hidden, "999");
287
237
  expect(await index.collectChanges()).toEqual([hidden]);
288
238
  });
289
239
 
290
- test.skipIf(asRoot)("does not report the files under it as deleted while it is unreadable", async () => {
240
+ test.skipIf(cannotLock)("does not report the files under it as deleted while it is unreadable", async () => {
291
241
  const root = await makeRoot();
292
242
  const abs = await seed(root, "locked/b.ts");
293
243
  const locked = path.dirname(abs);
294
- const index = new SourceMtimeIndex({ roots: [root] });
295
- await index.prime();
244
+ const index = await primed([root]);
296
245
  expect(index.trackedFileCount).toBe(1);
297
246
 
298
247
  await chmod(locked, 0o000);
299
248
 
300
- // A phantom deletion would rebuild for nothing and, worse, drop the file so a later real edit to it
301
- // goes unreported.
302
249
  expect(await index.collectChanges()).toEqual([]);
303
250
  expect(index.trackedFileCount).toBe(1);
304
- // The locked directory joins the list too whenever this scan happened to re-read it — which depends
305
- // on how fresh its mtime still was (see `dirSettleMs`) — so assert what the gap is about rather than
306
- // how many entries happen to describe it.
251
+ // Whether the locked directory itself is listed depends on its mtime freshness, so assert membership, not count.
307
252
  expect(index.coverageGaps.map((gap) => gap.path)).toContain(abs);
308
253
  expect(index.coverageGaps.every((gap) => gap.code === "EACCES")).toBe(true);
309
254
 
@@ -316,8 +261,7 @@ describe("SourceMtimeIndex", () => {
316
261
  test("is still forgotten when it is genuinely deleted, with no gap left behind", async () => {
317
262
  const root = await makeRoot();
318
263
  const abs = await seed(root, "lib/a.ts");
319
- const index = new SourceMtimeIndex({ roots: [root] });
320
- await index.prime();
264
+ const index = await primed([root]);
321
265
 
322
266
  await rm(path.dirname(abs), { recursive: true, force: true });
323
267
 
@@ -8,55 +8,21 @@ interface TrackedFile {
8
8
  size: number;
9
9
  }
10
10
 
11
- /**
12
- * A directory the index could not read, held with this mtime so every subsequent scan sees a mismatch
13
- * and retries it. `NaN !== NaN`, which is exactly the "always stale" semantics wanted here.
14
- */
11
+ // NaN never equals a real mtime, so a directory held with it is re-walked on every scan.
15
12
  const UNREADABLE = Number.NaN;
16
13
 
17
14
  export interface SourceMtimeIndexOptions {
18
15
  roots: string[];
19
16
  classifier?: HmrChangeClassifier;
20
- /**
21
- * How close to "now" a directory's mtime may be before this scan's reading of it is treated as
22
- * possibly stale. Raise it for a filesystem whose timestamps are coarser than the 1ms Linux stamps
23
- * directories with — some network mounts stamp whole seconds.
24
- */
17
+ /** Window (ms, default 20) in which a directory's mtime is too fresh to trust; raise it for whole-second stamps. */
25
18
  dirSettleMs?: number;
26
19
  }
27
20
 
28
- /**
29
- * A snapshot of every interesting source file's mtime under the watch roots, so the watcher can answer
30
- * "what actually changed" without trusting `fs.watch` event payloads.
31
- *
32
- * Bun 1.3.14's recursive `fs.watch` on macOS reports roughly **one path per ~200ms coalescing window**
33
- * and discards every other path changed in that window. Measured on this repo: saving 5 files together
34
- * reports 1; saving 20 reports 2; a single unrelated write landing first in the window hides a
35
- * concurrent save entirely. Node 22 reports all 20 on the same tree, so this is a Bun defect rather
36
- * than an FSEvents limitation (`local/optimize-resource/06-watcher-dropped-event.md`).
37
- *
38
- * Re-stating the tracked set costs ~15ms against ~1300 source files here, where a fresh walk costs
39
- * ~70ms — the walk has to visit ~40k entries (`ios/`, `android/`, `public/`) to find those 1300. So
40
- * changes are found by re-stating known files plus re-reading only the directories whose own mtime
41
- * moved, which is what adding or removing an entry bumps — with one exception for the window in which
42
- * that mtime cannot be trusted, see `#defaultDirSettleMs`.
43
- */
21
+ // Bun's recursive fs.watch reports about one path per ~200ms coalescing window and drops the rest, so changes are
22
+ // found by re-stating known files and re-reading only the directories whose own mtime moved.
44
23
  export class SourceMtimeIndex {
45
- /**
46
- * How close to "now" a directory's mtime may be before this scan's reading of it is treated as
47
- * possibly stale, and the directory re-read next time regardless of what its mtime says.
48
- *
49
- * Linux stamps directory times from a coarse clock. Measured on Bun 1.3.14 under Docker, 400
50
- * back-to-back `mkdir`s left the parent's mtime unmoved **319 times on overlayfs and 324 times on
51
- * ext4**, smallest observable step 1ms; macOS APFS (0.042ms) and a virtiofs bind mount (0.29ms) missed
52
- * none. So a directory mutated in the same millisecond as the value this index recorded — but after the
53
- * walk that recorded it — leaves no trace at all, and because its files were never tracked, later edits
54
- * to them go unreported too. That is permanent for the life of the process, which is what makes it worth
55
- * a second look rather than a note.
56
- *
57
- * 20ms covers a 1ms tick with room for a `HZ=100` kernel's 10ms, and costs one extra `readdir` per
58
- * directory touched in the last 20ms — during a save, a handful.
59
- */
24
+ // Linux stamps directory mtimes on a coarse clock (1ms; 10ms on HZ=100), so a mutation in the same tick as the
25
+ // recorded value leaves no trace — a directory that fresh is re-read on the next scan.
60
26
  static readonly #defaultDirSettleMs = 20;
61
27
  readonly #dirSettleMs: number;
62
28
  readonly #roots: string[];
@@ -64,7 +30,6 @@ export class SourceMtimeIndex {
64
30
  readonly #files = new Map<string, TrackedFile>();
65
31
  readonly #dirs = new Map<string, number>();
66
32
  readonly #unreadable = new Map<string, string>();
67
- /** Directories whose recorded mtime was too fresh to trust, re-read on the next scan. */
68
33
  readonly #unsettled = new Set<string>();
69
34
  #primed = false;
70
35
  #queue: Promise<unknown> = Promise.resolve();
@@ -83,27 +48,17 @@ export class SourceMtimeIndex {
83
48
  return this.#files.size;
84
49
  }
85
50
 
86
- /**
87
- * Paths the index currently cannot read, as `{ path, code }`. Non-empty means change detection has a
88
- * blind spot right now: anything edited underneath one of these is not reported until it recovers.
89
- *
90
- * Worth surfacing rather than swallowing, because the failure is silent by nature — a walk cannot tell
91
- * how much it did not see. Each entry is retried on every scan and clears itself on success.
92
- */
51
+ /** Paths the index cannot read right now; edits beneath them go unreported until a scan succeeds and clears them. */
93
52
  get coverageGaps(): { path: string; code: string }[] {
94
53
  return [...this.#unreadable].map(([file, code]) => ({ path: file, code }));
95
54
  }
96
55
 
97
- /**
98
- * Whether the last scan left a directory whose mtime was too fresh to trust. The caller should scan
99
- * again once the timestamp has settled, since nothing else will look at that directory until something
100
- * moves its mtime — and on a coarse clock the change that should have moved it already happened.
101
- */
56
+ /** True when the last scan left a directory too fresh to trust; scan again once it settles, since nothing else will. */
102
57
  get hasUnsettledDirs(): boolean {
103
58
  return this.#unsettled.size > 0;
104
59
  }
105
60
 
106
- /** Record the current state as the baseline. Reports nothing; call before the first `collectChanges`. */
61
+ /** Baseline the tree; reports nothing. Call before the first `collectChanges`. */
107
62
  async prime(): Promise<void> {
108
63
  await this.#serialize(async () => {
109
64
  this.#files.clear();
@@ -115,11 +70,7 @@ export class SourceMtimeIndex {
115
70
  });
116
71
  }
117
72
 
118
- /**
119
- * Absolute paths whose content changed (or which disappeared) since the last call, with the baseline
120
- * advanced to match. Returns an empty list until primed, so a failed prime degrades to watcher events
121
- * rather than reporting the entire tree as changed.
122
- */
73
+ /** Absolute paths changed or removed since the last call (the baseline advances); empty until primed. */
123
74
  async collectChanges(): Promise<string[]> {
124
75
  return this.#serialize(async () => {
125
76
  if (!this.#primed) return [];
@@ -130,29 +81,14 @@ export class SourceMtimeIndex {
130
81
  });
131
82
  }
132
83
 
133
- /**
134
- * Every scan runs to completion before the next begins.
135
- *
136
- * Overlapping scans corrupt each other rather than merely racing: one advances the baseline and prunes
137
- * entries while the other is midway through a key snapshot it took earlier, so a path the first has
138
- * already accounted for reads back as missing and is reported as a change that never happened. Observed
139
- * directly — a file nothing had written was reported, which downstream is a wasted rebuild.
140
- */
84
+ // Scans must not overlap: one pruning the baseline mid-snapshot of another reports a change nobody made.
141
85
  async #serialize<T>(work: () => Promise<T>): Promise<T> {
142
86
  const run = this.#queue.then(work, work);
143
- // Swallowed on the chain only; the caller still sees the rejection through `run`.
144
87
  this.#queue = run.catch(() => undefined);
145
88
  return run;
146
89
  }
147
90
 
148
- /**
149
- * Adopt writes the caller made itself — regenerated barrels, inserted imports — into the baseline so
150
- * the next `collectChanges` does not report them as a user edit.
151
- *
152
- * Narrow race worth knowing: a user save of the same path landing between the caller's write and this
153
- * call is baselined too, and therefore lost. It is sub-millisecond and no wider than the window the
154
- * event-based watcher already had.
155
- */
91
+ /** Baseline the caller's own writes so the next `collectChanges` skips them (a user save racing in between is lost). */
156
92
  async absorb(paths: string[]): Promise<void> {
157
93
  await this.#serialize(async () => {
158
94
  if (!this.#primed) return;
@@ -175,8 +111,7 @@ export class SourceMtimeIndex {
175
111
  [...this.#files.keys()].map(async (abs) => {
176
112
  const { stats, err } = await SourceMtimeIndex.#statPath(abs);
177
113
  if (!stats) {
178
- // Unreadable is not deleted. Dropping it here would both invent a change nobody made and stop
179
- // tracking the file, so a later real edit would go unreported.
114
+ // Unreadable is not deleted: dropping it would invent a change and stop tracking the file.
180
115
  if (err && !SourceMtimeIndex.#isMissing(err)) {
181
116
  this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
182
117
  return;
@@ -200,10 +135,6 @@ export class SourceMtimeIndex {
200
135
  );
201
136
  }
202
137
 
203
- /**
204
- * A directory's own mtime moves when an entry is added or removed but not when a tracked file's
205
- * content changes, so this finds creations and deletions the file pass cannot see.
206
- */
207
138
  async #collectDirChanges(changed: Set<string>): Promise<void> {
208
139
  const moved = new Set<string>();
209
140
  const gone: string[] = [];
@@ -218,34 +149,21 @@ export class SourceMtimeIndex {
218
149
  gone.push(dir);
219
150
  return;
220
151
  }
221
- // `UNREADABLE` is NaN, so a directory retained from a failed read always mismatches and is
222
- // rewalked here — that retry is what lets a transient failure recover on its own.
223
152
  if (stats.mtimeMs !== mtimeMs) moved.add(dir);
224
153
  }),
225
154
  );
226
- // An unsettled directory is re-read whether or not its mtime moved: on a coarse clock a mutation
227
- // that landed in the same tick as the recorded value leaves it identical, so the mtime is exactly
228
- // the signal that cannot be trusted here.
229
155
  for (const dir of this.#unsettled) if (this.#dirs.has(dir)) moved.add(dir);
230
156
  for (const dir of gone) this.#forget(dir);
231
- // Sequential: a moved directory can reveal a new subtree, and walking those in order keeps the
232
- // number of concurrent `readdir` calls proportional to the change rather than to the tree.
157
+ // Sequential so concurrent `readdir` calls scale with the change rather than with the tree.
233
158
  for (const dir of moved) await this.#walk(dir, changed);
234
159
  }
235
160
 
236
- /**
237
- * Re-read one directory, tracking entries that are new or changed. Recurses only into subdirectories
238
- * that are new to the index; known ones are covered by their own mtime check in `#collectDirChanges`.
239
- */
240
161
  async #walk(dir: string, changed: Set<string> | null): Promise<void> {
241
162
  const [listing, dirStat] = await Promise.all([SourceMtimeIndex.#readdirPath(dir), SourceMtimeIndex.#statPath(dir)]);
242
163
  const entries = listing.entries;
243
164
  const dirStats = dirStat.stats;
244
165
  if (!entries || !dirStats?.isDirectory()) {
245
- // A directory that cannot be read is not a directory that is gone. Forgetting it here is
246
- // unrecoverable: nothing re-stats a dir the index has dropped, and the parent's own mtime does not
247
- // move when a child merely becomes unreadable, so the whole subtree stays invisible for the life of
248
- // the process. Measured: one `EACCES` at prime silently hid 2 of 3 files, permanently.
166
+ // Unreadable is not gone: nothing re-stats a forgotten directory, so its subtree would stay invisible for good.
249
167
  const err = listing.err ?? dirStat.err;
250
168
  if (err && !SourceMtimeIndex.#isMissing(err)) {
251
169
  this.#markUnreadable(dir, err);
@@ -274,9 +192,7 @@ export class SourceMtimeIndex {
274
192
  present.add(abs);
275
193
  const { stats, err } = await SourceMtimeIndex.#statPath(abs);
276
194
  if (!stats?.isFile()) {
277
- // A file listed but not stat-able has no baseline, so an edit to it would compare against nothing.
278
- // Leaving the directory stale is what brings it back; the file pass cannot, since it only revisits
279
- // paths already tracked.
195
+ // No baseline for an unstat-able file; leaving the directory stale (below) is what retries it.
280
196
  if (err && !SourceMtimeIndex.#isMissing(err)) {
281
197
  this.#unreadable.set(abs, err.code ?? "EUNKNOWN");
282
198
  blind = true;
@@ -289,8 +205,6 @@ export class SourceMtimeIndex {
289
205
  this.#files.set(abs, { mtimeMs: stats.mtimeMs, size: stats.size });
290
206
  changed?.add(abs);
291
207
  }
292
- // Entries this directory used to hold and no longer does. Only worth scanning for a directory the
293
- // index already knew, which during `prime` is none of them.
294
208
  if (known) {
295
209
  for (const abs of [...this.#files.keys()]) {
296
210
  if (present.has(abs) || path.dirname(abs) !== dir) continue;
@@ -298,51 +212,35 @@ export class SourceMtimeIndex {
298
212
  changed?.add(abs);
299
213
  }
300
214
  }
301
- // `UNREADABLE` already forces a re-walk every scan, so the freshness retry would only duplicate it.
302
215
  if (blind) {
303
216
  this.#dirs.set(dir, UNREADABLE);
304
217
  this.#unsettled.delete(dir);
305
218
  }
306
- // Known subdirectories carry their own mtime check, so only unseen ones need walking. Concurrent
307
- // because `prime` reaches every directory through here.
219
+ // Known subdirectories carry their own mtime check; concurrent because `prime` walks the whole tree here.
308
220
  await Promise.all(descend.filter((sub) => !this.#dirs.has(sub)).map((sub) => this.#walk(sub, changed)));
309
221
  }
310
222
 
311
- /**
312
- * Keep a directory the index could not read, flagged for retry on every scan, and keep whatever it
313
- * already knows underneath it — those files stay tracked so a real edit is still caught the moment
314
- * access returns.
315
- */
316
223
  #markUnreadable(dir: string, err: NodeJS.ErrnoException): void {
317
224
  this.#dirs.set(dir, UNREADABLE);
318
225
  this.#unreadable.set(dir, err.code ?? "EUNKNOWN");
319
226
  this.#unsettled.delete(dir);
320
227
  }
321
228
 
322
- /** Drop a directory and everything the index holds beneath it. */
323
229
  #forget(dir: string): void {
324
230
  const prefix = `${dir}${path.sep}`;
325
231
  this.#dirs.delete(dir);
326
232
  this.#unreadable.delete(dir);
327
233
  this.#unsettled.delete(dir);
328
234
  for (const known of [...this.#dirs.keys()]) if (known.startsWith(prefix)) this.#dirs.delete(known);
329
- // Otherwise a gap under a deleted directory is reported forever, since nothing revisits it to clear.
330
235
  for (const known of [...this.#unreadable.keys()]) if (known.startsWith(prefix)) this.#unreadable.delete(known);
331
- // Left behind, this would re-walk a path that no longer exists on every scan.
332
236
  for (const known of [...this.#unsettled]) if (known.startsWith(prefix)) this.#unsettled.delete(known);
333
237
  }
334
238
 
335
- /**
336
- * Whether a directory's mtime is recent enough that another mutation could share its timestamp tick.
337
- *
338
- * Symmetric so a filesystem clock running *ahead* of this process — a network mount, a container with a
339
- * skewed host — does not read as permanently fresh and re-walk the whole tree on every scan.
340
- */
239
+ // Symmetric so a filesystem clock running ahead of this process does not read as permanently fresh.
341
240
  #isUnsettled(mtimeMs: number): boolean {
342
241
  return Math.abs(Date.now() - mtimeMs) < this.#dirSettleMs;
343
242
  }
344
243
 
345
- /** `null` stats with the errno kept, so callers can tell "not there" from "could not look". */
346
244
  static async #statPath(abs: string): Promise<{ stats: Stats | null; err: NodeJS.ErrnoException | null }> {
347
245
  try {
348
246
  return { stats: await stat(abs), err: null };
@@ -359,20 +257,11 @@ export class SourceMtimeIndex {
359
257
  }
360
258
  }
361
259
 
362
- /**
363
- * Whether an errno means the path genuinely is not there, which is the only case where dropping it from
364
- * the baseline is right. Everything else — `EACCES`, `EIO`, `EMFILE`, a stalled network mount — means the
365
- * index failed to look, and treating that as a deletion both invents a change and blinds it to real ones.
366
- */
367
260
  static #isMissing(err: NodeJS.ErrnoException): boolean {
368
261
  return err.code === "ENOENT" || err.code === "ENOTDIR";
369
262
  }
370
263
 
371
- /**
372
- * Mirrors `HmrChangeClassifier`'s path rules for directory names — dotted names cover `.git` and
373
- * `.akan`, which is where every build artifact lands. Symlinked entries are neither `isFile` nor
374
- * `isDirectory` and so are skipped, matching `fs.watch`, which does not follow them either.
375
- */
264
+ // Mirrors HmrChangeClassifier's rules; symlinks are neither file nor directory, so they are skipped as fs.watch does.
376
265
  static #skipDirent(name: string): boolean {
377
266
  return !name || name.startsWith(".") || name === "node_modules";
378
267
  }