@akanjs/devkit 3.0.0-beta.3 → 3.0.0-beta.30
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.
- package/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +2 -2
- package/README.md +3 -2
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +404 -141
- package/akanConfig/akanConfig.ts +317 -274
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +21 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +35 -2
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +71 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/csrE2e/csrE2eHarness.fixture.ts +505 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +252 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +31 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +269 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +105 -199
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +204 -0
- package/mobile/desktopServerStage.ts +138 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +151 -0
- package/mobile/nativeApp.ts +405 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +414 -0
- package/mobile/nativeConfig.ts +296 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +30 -25
- package/subspace.ts +81 -115
- package/subspaceConfig.ts +11 -9
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { chmod, mkdir,
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
210
|
-
await
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|