@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.4

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.
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import type { Logger } from "akanjs/common";
4
4
  import type { ChangeBatch, ChangeKind } from "akanjs/server";
5
5
  import { HmrChangeClassifier } from "./hmrChangeClassifier";
6
+ import { SourceMtimeIndex } from "./sourceMtimeIndex";
6
7
 
7
8
  export type { ChangeBatch, ChangeKind };
8
9
 
@@ -11,6 +12,11 @@ export interface WatcherOptions {
11
12
  debounceMs?: number;
12
13
  logger: Logger;
13
14
  onBatch: (batch: ChangeBatch) => void | Promise<void>;
15
+ /**
16
+ * Delay before re-checking mtimes for changes `fs.watch` never reported. Must clear Bun's ~200ms
17
+ * coalescing window, or a write late in the same window as a delivered event stays invisible.
18
+ */
19
+ verifyDelayMs?: number;
14
20
  }
15
21
 
16
22
  /**
@@ -18,27 +24,55 @@ export interface WatcherOptions {
18
24
  * classification coarse (`code` / `css` / `config`) so the orchestrator can
19
25
  * decide whether a full rebuild-and-reload or a narrower action (e.g. CSS
20
26
  * hot-swap) is sufficient.
27
+ *
28
+ * Bun's event payloads are treated as a hint, not the answer: its recursive `fs.watch` reports about one
29
+ * path per coalescing window and silently discards the rest, so what changed is resolved against a
30
+ * `SourceMtimeIndex` instead (see that class for the measurements). Events still drive *when* to look,
31
+ * which is what keeps this cheap — Bun does reliably deliver at least one event per window.
21
32
  */
22
33
  export class HmrWatcher {
23
34
  readonly #roots: string[];
24
35
  readonly #debounceMs: number;
36
+ readonly #verifyDelayMs: number;
25
37
  readonly #onBatch: WatcherOptions["onBatch"];
26
38
  readonly #logger: Logger;
27
39
  readonly #watchers: fs.FSWatcher[] = [];
28
40
  readonly #pending = new Map<string, Exclude<ChangeKind, "ignore">>();
41
+ /** Paths the watcher's own events named since the last batch — diagnostics only, see `#queue`. */
42
+ readonly #hinted = new Set<string>();
29
43
  readonly #classifier = new HmrChangeClassifier();
44
+ readonly #index: SourceMtimeIndex;
30
45
  #timer: ReturnType<typeof setTimeout> | null = null;
46
+ #verifyTimer: ReturnType<typeof setTimeout> | null = null;
31
47
  #stopped = false;
32
48
  #flushing = false;
49
+ #unreportedChanges = 0;
50
+ #reportedCompensating = false;
51
+ #reportedGaps = "";
33
52
 
34
53
  constructor(opts: WatcherOptions) {
35
54
  this.#roots = [...new Set(opts.roots.map((r) => path.resolve(r)))];
36
55
  this.#debounceMs = opts.debounceMs ?? 80;
56
+ this.#verifyDelayMs = opts.verifyDelayMs ?? 250;
37
57
  this.#onBatch = opts.onBatch;
38
58
  this.#logger = opts.logger;
59
+ this.#index = new SourceMtimeIndex({ roots: this.#roots, classifier: this.#classifier });
39
60
  }
40
61
 
41
- start(): void {
62
+ /**
63
+ * How many changes the mtime scan found that `fs.watch` never reported. Non-zero means this watcher is
64
+ * compensating for the Bun defect rather than the defect being absent, which is the number to watch if
65
+ * it is ever fixed upstream.
66
+ */
67
+ get unreportedChanges(): number {
68
+ return this.#unreportedChanges;
69
+ }
70
+
71
+ /**
72
+ * Watchers are installed before the baseline is taken, so an edit made during priming is reported by
73
+ * the event side even though the baseline already reflects it.
74
+ */
75
+ async start(): Promise<void> {
42
76
  for (const root of this.#roots) {
43
77
  try {
44
78
  const w = fs.watch(root, { recursive: true, persistent: false }, (_event, filename) => {
@@ -52,11 +86,23 @@ export class HmrWatcher {
52
86
  this.#logger.error(`[hmr] failed to watch ${root}: ${(err as Error).message}`);
53
87
  }
54
88
  }
89
+ try {
90
+ await this.#index.prime();
91
+ // Before the first batch, because a root that is unreadable at boot blinds the whole session and
92
+ // waiting for a save to surface it means waiting for a save that never rebuilds.
93
+ this.#reportCoverageGaps();
94
+ this.#logger.verbose(`[hmr] tracking ${this.#index.trackedFileCount} source files for change verification`);
95
+ } catch (err) {
96
+ this.#logger.error(
97
+ `[hmr] mtime index unavailable; falling back to watcher events alone, which drop concurrent saves: ${(err as Error).message}`,
98
+ );
99
+ }
55
100
  }
56
101
 
57
102
  stop(): void {
58
103
  this.#stopped = true;
59
104
  if (this.#timer) clearTimeout(this.#timer);
105
+ if (this.#verifyTimer) clearTimeout(this.#verifyTimer);
60
106
  for (const w of this.#watchers) {
61
107
  try {
62
108
  w.close();
@@ -66,10 +112,46 @@ export class HmrWatcher {
66
112
  }
67
113
  }
68
114
 
115
+ /**
116
+ * Adopt writes the batch handler made itself (regenerated barrels, inserted imports) so the
117
+ * verification scan does not spend a second generation rebuilding content this one already consumed.
118
+ */
119
+ async absorb(paths: string[]): Promise<void> {
120
+ if (paths.length === 0) return;
121
+ await this.#index.absorb(paths);
122
+ }
123
+
124
+ /**
125
+ * An event says only *that* something happened, never reliably *what*. So with a baseline in hand it is
126
+ * used purely to decide when to look, and the mtime scan names the files.
127
+ *
128
+ * Taking the payload as well would double-report: Bun does deliver a real event for some of the paths a
129
+ * scan has already emitted, and adding it back here produced a second batch for the same save — one more
130
+ * generation and one more build for no change.
131
+ */
69
132
  #queue(abs: string): void {
70
133
  const kind = this.#classifier.classify(abs);
71
- if (kind === "ignore") return;
72
- this.#pending.set(abs, kind);
134
+ if (!this.#index.primed) {
135
+ // Still priming, or priming failed: the payload is the only signal there is.
136
+ if (kind === "ignore") return;
137
+ this.#pending.set(abs, kind);
138
+ this.#scheduleFlush();
139
+ return;
140
+ }
141
+ // An ignored path still means a window happened. That is the shape of the original bug — every build
142
+ // ends in a burst under `.akan/`, and the burst is what Bun reports instead of the save beside it.
143
+ // Rescheduling on each one folds a build's whole burst into a single scan once it goes quiet.
144
+ if (kind === "ignore") {
145
+ this.#scheduleVerify();
146
+ return;
147
+ }
148
+ // Recorded only so `unreportedChanges` can tell which changes the events did name; it never decides
149
+ // what is in a batch.
150
+ this.#hinted.add(abs);
151
+ this.#scheduleFlush();
152
+ }
153
+
154
+ #scheduleFlush(): void {
73
155
  if (this.#flushing) return;
74
156
  if (this.#timer) clearTimeout(this.#timer);
75
157
  this.#timer = setTimeout(() => this.#flush(), this.#debounceMs);
@@ -77,17 +159,20 @@ export class HmrWatcher {
77
159
 
78
160
  #flush(): void {
79
161
  this.#timer = null;
80
- if (this.#stopped || this.#pending.size === 0 || this.#flushing) return;
162
+ if (this.#stopped || this.#flushing) return;
81
163
  void this.#drain();
82
164
  }
83
165
 
84
166
  async #drain(): Promise<void> {
85
167
  this.#flushing = true;
86
168
  try {
87
- while (!this.#stopped && this.#pending.size > 0) {
169
+ while (!this.#stopped) {
170
+ await this.#mergeDetectedChanges();
171
+ if (this.#pending.size === 0) break;
88
172
  const files = Array.from(this.#pending.keys());
89
173
  const kinds = new Set(this.#pending.values());
90
174
  this.#pending.clear();
175
+ this.#hinted.clear();
91
176
  try {
92
177
  await this.#onBatch({ files, kinds });
93
178
  } catch (e) {
@@ -97,6 +182,92 @@ export class HmrWatcher {
97
182
  } finally {
98
183
  this.#flushing = false;
99
184
  if (!this.#stopped && this.#pending.size > 0) this.#timer = setTimeout(() => this.#flush(), this.#debounceMs);
185
+ else this.#scheduleVerify();
100
186
  }
101
187
  }
188
+
189
+ /**
190
+ * Fold in everything the mtime index has seen change, since a delivered event names at most one of the
191
+ * paths that moved in its window.
192
+ */
193
+ async #mergeDetectedChanges(): Promise<void> {
194
+ const detected = await this.#index.collectChanges().catch((err) => {
195
+ this.#logger.error(`[hmr] mtime scan failed: ${(err as Error).message}`);
196
+ return [] as string[];
197
+ });
198
+ this.#reportCoverageGaps();
199
+ let unreported = 0;
200
+ for (const abs of detected) {
201
+ const kind = this.#classifier.classify(abs);
202
+ if (kind === "ignore") continue;
203
+ if (!this.#hinted.has(abs)) unreported += 1;
204
+ this.#pending.set(abs, kind);
205
+ }
206
+ if (unreported === 0) return;
207
+ this.#unreportedChanges += unreported;
208
+ // Once at info, so it is visible that the watcher is compensating rather than the defect being absent;
209
+ // per-batch detail stays at verbose because a save-all trips this on every save.
210
+ if (!this.#reportedCompensating) {
211
+ this.#reportedCompensating = true;
212
+ this.#logger.info(
213
+ `[hmr] recovered ${unreported} change(s) that fs.watch did not report; Bun coalesces concurrent saves and drops all but one, so changes are resolved by mtime`,
214
+ );
215
+ }
216
+ this.#logger.verbose(
217
+ `[hmr] mtime scan found ${unreported} change(s) fs.watch never reported (${this.#unreportedChanges} total)`,
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Say so when part of the tree cannot be read, and say so again when it recovers.
223
+ *
224
+ * A blind spot here means edits under that path are not rebuilt at all, which is indistinguishable from
225
+ * the dev server being broken. Keyed on the gap list itself so a persistent failure logs once rather than
226
+ * once per save, while a *different* gap appearing still gets its own line.
227
+ */
228
+ #reportCoverageGaps(): void {
229
+ const gaps = this.#index.coverageGaps;
230
+ const key = gaps
231
+ .map((gap) => `${gap.code}:${gap.path}`)
232
+ .sort()
233
+ .join("|");
234
+ if (key === this.#reportedGaps) return;
235
+ this.#reportedGaps = key;
236
+ if (gaps.length === 0) {
237
+ this.#logger.info("[hmr] all watch roots readable again; change detection is complete");
238
+ return;
239
+ }
240
+ const shown = gaps
241
+ .slice(0, 3)
242
+ .map((gap) => `${gap.path} (${gap.code})`)
243
+ .join(", ");
244
+ const rest = gaps.length > 3 ? ` and ${gaps.length - 3} more` : "";
245
+ this.#logger.warn(
246
+ `[hmr] cannot read ${gaps.length} path(s), so edits underneath them will not rebuild: ${shown}${rest}`,
247
+ );
248
+ }
249
+
250
+ /**
251
+ * One scan after the coalescing window closes. A write that lands in the same window as an already
252
+ * delivered event produces no further event of its own, so nothing else would ever look for it.
253
+ */
254
+ #scheduleVerify(): void {
255
+ if (this.#stopped || this.#verifyDelayMs <= 0) return;
256
+ if (this.#verifyTimer) clearTimeout(this.#verifyTimer);
257
+ this.#verifyTimer = setTimeout(() => {
258
+ this.#verifyTimer = null;
259
+ void this.#verify();
260
+ }, this.#verifyDelayMs);
261
+ }
262
+
263
+ /**
264
+ * Terminates rather than looping: a scan that finds nothing schedules nothing, and the build writes its
265
+ * artifacts under `.akan/` — which the classifier ignores — while codegen writes are content-guarded,
266
+ * so a rebuild does not move a tracked mtime.
267
+ */
268
+ async #verify(): Promise<void> {
269
+ if (this.#stopped || this.#flushing) return;
270
+ await this.#mergeDetectedChanges();
271
+ if (this.#pending.size > 0) await this.#drain();
272
+ }
102
273
  }
@@ -16,6 +16,7 @@ export * from "./pagesEntrySourceGenerator";
16
16
  export * from "./precompressArtifacts";
17
17
  export * from "./routeClientBuilder";
18
18
  export * from "./routesManifestArtifactSerializer";
19
+ export * from "./sourceMtimeIndex";
19
20
  export * from "./ssrBaseArtifactBuilder";
20
21
  export * from "./vendorSpecifiers";
21
22
  export * from "./watchRootResolver";
@@ -0,0 +1,280 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { SourceMtimeIndex } from "./sourceMtimeIndex";
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
+ const seed = async (root: string, rel: string, content = "export const x = 1;\n") => {
16
+ const abs = path.join(root, rel);
17
+ await mkdir(path.dirname(abs), { recursive: true });
18
+ await writeFile(abs, content);
19
+ return abs;
20
+ };
21
+
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
+ */
27
+ const rewrite = (abs: string, marker: string) => writeFile(abs, `export const x = ${marker};\n`);
28
+
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
+ */
33
+ const forceRemove = async (target: string): Promise<void> => {
34
+ await rm(target, { recursive: true, force: true }).catch(async () => {
35
+ await chmod(target, 0o755).catch(() => undefined);
36
+ const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
37
+ for (const entry of entries) if (entry.isDirectory()) await forceRemove(path.join(target, entry.name));
38
+ await rm(target, { recursive: true, force: true });
39
+ });
40
+ };
41
+
42
+ afterEach(async () => {
43
+ await Promise.all(roots.splice(0).map((root) => forceRemove(root)));
44
+ });
45
+
46
+ describe("SourceMtimeIndex", () => {
47
+ test("reports nothing on a quiet tree", async () => {
48
+ const root = await makeRoot();
49
+ await seed(root, "lib/a.ts");
50
+ const index = new SourceMtimeIndex({ roots: [root] });
51
+ await index.prime();
52
+
53
+ expect(index.trackedFileCount).toBe(1);
54
+ expect(await index.collectChanges()).toEqual([]);
55
+ expect(await index.collectChanges()).toEqual([]);
56
+ });
57
+
58
+ test("reports every file of a save-all, which is what fs.watch loses", async () => {
59
+ const root = await makeRoot();
60
+ 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();
63
+
64
+ // No gaps: exactly the burst Bun collapses to a single reported path.
65
+ for (const [i, abs] of files.entries()) await rewrite(abs, `${i}00`);
66
+
67
+ expect((await index.collectChanges()).sort()).toEqual([...files].sort());
68
+ });
69
+
70
+ test("reports a change once, then stops reporting it", async () => {
71
+ const root = await makeRoot();
72
+ const abs = await seed(root, "lib/a.ts");
73
+ const index = new SourceMtimeIndex({ roots: [root] });
74
+ await index.prime();
75
+
76
+ await rewrite(abs, "222");
77
+ expect(await index.collectChanges()).toEqual([abs]);
78
+ expect(await index.collectChanges()).toEqual([]);
79
+ });
80
+
81
+ test("reports a created file, found through its directory's mtime", async () => {
82
+ const root = await makeRoot();
83
+ await seed(root, "lib/a.ts");
84
+ const index = new SourceMtimeIndex({ roots: [root] });
85
+ await index.prime();
86
+
87
+ const created = await seed(root, "lib/b.ts");
88
+ expect(await index.collectChanges()).toEqual([created]);
89
+ expect(await index.collectChanges()).toEqual([]);
90
+ });
91
+
92
+ test("reports a created directory's files", async () => {
93
+ const root = await makeRoot();
94
+ await seed(root, "lib/a.ts");
95
+ const index = new SourceMtimeIndex({ roots: [root] });
96
+ await index.prime();
97
+
98
+ const created = await seed(root, "lib/user/user.constant.ts");
99
+ expect(await index.collectChanges()).toEqual([created]);
100
+ expect(await index.collectChanges()).toEqual([]);
101
+ });
102
+
103
+ test("reports a deleted file and a deleted directory's files", async () => {
104
+ const root = await makeRoot();
105
+ const kept = await seed(root, "lib/a.ts");
106
+ const removed = await seed(root, "lib/gone/b.ts");
107
+ const index = new SourceMtimeIndex({ roots: [root] });
108
+ await index.prime();
109
+
110
+ await rm(path.join(root, "lib/gone"), { recursive: true, force: true });
111
+ expect(await index.collectChanges()).toEqual([removed]);
112
+ expect(await index.collectChanges()).toEqual([]);
113
+ expect(await index.collectChanges()).not.toContain(kept);
114
+ });
115
+
116
+ test("ignores build output and node_modules", async () => {
117
+ const root = await makeRoot();
118
+ await seed(root, "lib/a.ts");
119
+ const index = new SourceMtimeIndex({ roots: [root] });
120
+ await index.prime();
121
+ expect(index.trackedFileCount).toBe(1);
122
+
123
+ await seed(root, ".akan/artifact/server/pages-1.js");
124
+ await seed(root, "node_modules/dep/index.ts");
125
+ expect(await index.collectChanges()).toEqual([]);
126
+ expect(index.trackedFileCount).toBe(1);
127
+ });
128
+
129
+ test("ignores files no classifier kind applies to", async () => {
130
+ const root = await makeRoot();
131
+ await seed(root, "lib/a.ts");
132
+ const index = new SourceMtimeIndex({ roots: [root] });
133
+ await index.prime();
134
+
135
+ await seed(root, "public/logo.svg", "<svg/>");
136
+ expect(await index.collectChanges()).toEqual([]);
137
+ });
138
+
139
+ test("absorb adopts a write instead of reporting it", async () => {
140
+ const root = await makeRoot();
141
+ const abs = await seed(root, "lib/index.ts");
142
+ const index = new SourceMtimeIndex({ roots: [root] });
143
+ await index.prime();
144
+
145
+ await rewrite(abs, "333");
146
+ await index.absorb([abs]);
147
+ expect(await index.collectChanges()).toEqual([]);
148
+ });
149
+
150
+ test("absorb of an unknown path does not start tracking a change", async () => {
151
+ const root = await makeRoot();
152
+ await seed(root, "lib/a.ts");
153
+ const index = new SourceMtimeIndex({ roots: [root] });
154
+ await index.prime();
155
+
156
+ const created = await seed(root, "lib/generated.ts");
157
+ await index.absorb([created]);
158
+ expect(await index.collectChanges()).toEqual([]);
159
+ });
160
+
161
+ test("counts a file once when a root is nested inside another root", async () => {
162
+ const root = await makeRoot();
163
+ await seed(root, "page/_index.tsx");
164
+ const nested = new SourceMtimeIndex({ roots: [root, path.join(root, "page")] });
165
+ await nested.prime();
166
+ const flat = new SourceMtimeIndex({ roots: [root] });
167
+ await flat.prime();
168
+
169
+ expect(nested.trackedFileCount).toBe(flat.trackedFileCount);
170
+ });
171
+
172
+ test("concurrent scans do not invent a change", async () => {
173
+ const root = await makeRoot();
174
+ await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
175
+ const index = new SourceMtimeIndex({ roots: [root] });
176
+ await index.prime();
177
+
178
+ // Overlapping scans used to corrupt each other's view of the baseline: one pruned an entry the other
179
+ // had already snapshotted, and the path came back as a change nothing had written.
180
+ const rounds = await Promise.all([1, 2, 3, 4].map(() => index.collectChanges()));
181
+ expect(rounds.flat()).toEqual([]);
182
+ });
183
+
184
+ test("concurrent scans report a real change exactly once between them", async () => {
185
+ const root = await makeRoot();
186
+ const abs = await seed(root, "lib/a.ts");
187
+ const index = new SourceMtimeIndex({ roots: [root] });
188
+ await index.prime();
189
+
190
+ await rewrite(abs, "444");
191
+ const rounds = await Promise.all([1, 2, 3].map(() => index.collectChanges()));
192
+ expect(rounds.flat()).toEqual([abs]);
193
+ });
194
+
195
+ test("reports nothing before priming rather than treating the tree as new", async () => {
196
+ const root = await makeRoot();
197
+ await seed(root, "lib/a.ts");
198
+ const index = new SourceMtimeIndex({ roots: [root] });
199
+
200
+ expect(index.primed).toBe(false);
201
+ expect(await index.collectChanges()).toEqual([]);
202
+ });
203
+
204
+ describe("a directory it cannot read", () => {
205
+ /**
206
+ * `chmod 000` does not stop root, so these would assert the opposite of what they mean when the suite
207
+ * runs as root (CI containers commonly do).
208
+ */
209
+ const asRoot = process.getuid?.() === 0;
210
+
211
+ test.skipIf(asRoot)("is reported as a gap instead of silently skipped", async () => {
212
+ const root = await makeRoot();
213
+ await seed(root, "open/a.ts");
214
+ const hidden = await seed(root, "locked/b.ts");
215
+ await chmod(path.dirname(hidden), 0o000);
216
+
217
+ const index = new SourceMtimeIndex({ roots: [root] });
218
+ await index.prime();
219
+
220
+ // Priming still succeeds — it just cannot see everything, and that is the part that must be said out
221
+ // loud rather than inferred from a file count nobody is checking.
222
+ expect(index.primed).toBe(true);
223
+ expect(index.trackedFileCount).toBe(1);
224
+ expect(index.coverageGaps).toEqual([{ path: path.dirname(hidden), code: "EACCES" }]);
225
+ });
226
+
227
+ test.skipIf(asRoot)("becomes visible again once it is readable, and clears the gap", async () => {
228
+ const root = await makeRoot();
229
+ const hidden = await seed(root, "locked/b.ts");
230
+ const locked = path.dirname(hidden);
231
+ await chmod(locked, 0o000);
232
+ const index = new SourceMtimeIndex({ roots: [root] });
233
+ await index.prime();
234
+
235
+ await chmod(locked, 0o755);
236
+
237
+ // Before the fix this stayed empty forever: the directory had been forgotten, so nothing re-stat'd
238
+ // it, and its parent's mtime never moves when a child merely becomes readable again.
239
+ expect(await index.collectChanges()).toEqual([hidden]);
240
+ expect(index.coverageGaps).toEqual([]);
241
+ await rewrite(hidden, "999");
242
+ expect(await index.collectChanges()).toEqual([hidden]);
243
+ });
244
+
245
+ test.skipIf(asRoot)("does not report the files under it as deleted while it is unreadable", async () => {
246
+ const root = await makeRoot();
247
+ const abs = await seed(root, "locked/b.ts");
248
+ const locked = path.dirname(abs);
249
+ const index = new SourceMtimeIndex({ roots: [root] });
250
+ await index.prime();
251
+ expect(index.trackedFileCount).toBe(1);
252
+
253
+ await chmod(locked, 0o000);
254
+
255
+ // A phantom deletion would rebuild for nothing and, worse, drop the file so a later real edit to it
256
+ // goes unreported.
257
+ expect(await index.collectChanges()).toEqual([]);
258
+ expect(index.trackedFileCount).toBe(1);
259
+ expect(index.coverageGaps.map((gap) => gap.code)).toEqual(["EACCES"]);
260
+
261
+ await chmod(locked, 0o755);
262
+ await rewrite(abs, "1234");
263
+ expect(await index.collectChanges()).toEqual([abs]);
264
+ expect(index.coverageGaps).toEqual([]);
265
+ });
266
+
267
+ test("is still forgotten when it is genuinely deleted, with no gap left behind", async () => {
268
+ const root = await makeRoot();
269
+ const abs = await seed(root, "lib/a.ts");
270
+ const index = new SourceMtimeIndex({ roots: [root] });
271
+ await index.prime();
272
+
273
+ await rm(path.dirname(abs), { recursive: true, force: true });
274
+
275
+ expect(await index.collectChanges()).toEqual([abs]);
276
+ expect(index.coverageGaps).toEqual([]);
277
+ expect(await index.collectChanges()).toEqual([]);
278
+ });
279
+ });
280
+ });