@akanjs/devkit 2.4.1-rc.6 → 2.4.1

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.
@@ -14,6 +14,16 @@ const AKANJS_NODE_MODULE_RE = /[\\/]node_modules[\\/]akanjs[\\/]/;
14
14
  const NON_SOURCE_EXT_RE = /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav)$/i;
15
15
  type PackageResolver = Awaited<ReturnType<typeof createTsconfigPackageResolver>>;
16
16
 
17
+ /**
18
+ * Everything the traversal ever asks of a file, which is why its text is not kept.
19
+ *
20
+ * `imports` is empty for a client entry: the walk stops there, so they are never scanned.
21
+ */
22
+ interface FileFacts {
23
+ isClientEntry: boolean;
24
+ imports: ScannedImport[];
25
+ }
26
+
17
27
  const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
18
28
 
19
29
  /**
@@ -29,12 +39,23 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
29
39
  #analyzer: BarrelAnalyzer;
30
40
  #tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
31
41
  #fileExistsCache = new Map<string, Promise<boolean>>();
32
- #readCache = new Map<string, Promise<string | null>>();
33
- #rewriteCache = new Map<string, Promise<string>>();
34
- #importCache = new Map<string, Promise<ScannedImport[]>>();
42
+ /**
43
+ * Derived facts per file, never the source text.
44
+ *
45
+ * This instance lives as long as the builder process does — it is rebuilt only when the akan config
46
+ * changes — so caching whole source texts (and a second, barrel-rewritten copy of each) meant the
47
+ * watcher held every source file it had ever walked for the whole dev session. Nothing downstream
48
+ * wanted the text: the walk needs one boolean and one import list per file, and both were already
49
+ * cached separately under the same key.
50
+ */
51
+ #factsCache = new Map<string, Promise<FileFacts | null>>();
35
52
  #resolvedFileCache = new Map<string, Promise<string | null>>();
36
53
  #resolvedSpecifierCache = new Map<string, Promise<string | null>>();
37
54
  #reachableEntriesCache = new Map<string, Set<string>>();
55
+ /** Keys of the three caches above whose answer was "not there" — see `#forgetMissing`. */
56
+ #missingFiles = new Set<string>();
57
+ #unresolvedPaths = new Set<string>();
58
+ #unresolvedSpecifiers = new Set<string>();
38
59
 
39
60
  constructor(akanConfig: AkanConfig, resolvePackage: PackageResolver) {
40
61
  this.#akanConfig = akanConfig;
@@ -57,38 +78,88 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
57
78
  invalidate(files: string[]): void {
58
79
  for (const file of files) {
59
80
  const absPath = path.resolve(file);
60
- this.#readCache.delete(absPath);
61
- this.#rewriteCache.delete(absPath);
62
- this.#importCache.delete(absPath);
81
+ this.#factsCache.delete(absPath);
82
+ this.#fileExistsCache.delete(absPath);
63
83
  this.#reachableEntriesCache.delete(absPath);
64
84
  }
85
+ if (files.length === 0) return;
65
86
  // Parent files cache the transitive result of their imports, so a changed
66
87
  // child can affect any reachable-entry cache above it.
67
- if (files.length > 0) this.#reachableEntriesCache.clear();
88
+ this.#reachableEntriesCache.clear();
89
+ this.#forgetMissing();
90
+ }
91
+
92
+ /**
93
+ * Drop every "there is no such file" answer, because a batch may be what created it.
94
+ *
95
+ * These caches are keyed by extension-less path and by `dir\0specifier`, neither of which maps back
96
+ * to the path that just appeared, so a negative recorded before a module existed is unreachable any
97
+ * other way — and this instance lives as long as the builder process. Positive answers are kept:
98
+ * they are keyed by a real path, which arrives in `files` when it changes or goes away.
99
+ */
100
+ #forgetMissing(): void {
101
+ for (const key of this.#missingFiles) this.#fileExistsCache.delete(key);
102
+ for (const key of this.#unresolvedPaths) this.#resolvedFileCache.delete(key);
103
+ for (const key of this.#unresolvedSpecifiers) this.#resolvedSpecifierCache.delete(key);
104
+ this.#missingFiles.clear();
105
+ this.#unresolvedPaths.clear();
106
+ this.#unresolvedSpecifiers.clear();
68
107
  }
69
108
 
70
109
  async #fileExists(p: string): Promise<boolean> {
71
110
  const absPath = path.resolve(p);
72
111
  let cached = this.#fileExistsCache.get(absPath);
73
112
  if (!cached) {
74
- cached = Bun.file(absPath).exists();
113
+ cached = Bun.file(absPath)
114
+ .exists()
115
+ .then((exists) => {
116
+ if (!exists) this.#missingFiles.add(absPath);
117
+ return exists;
118
+ });
75
119
  this.#fileExistsCache.set(absPath, cached);
76
120
  }
77
121
  return cached;
78
122
  }
79
123
 
80
- #readFile(file: string): Promise<string | null> {
124
+ /**
125
+ * Read a file once and keep only what the traversal asks of it. The text itself is dropped as soon
126
+ * as the boolean and the import list are out of it — see `#factsCache`.
127
+ */
128
+ #facts(file: string): Promise<FileFacts | null> {
81
129
  const absPath = path.resolve(file);
82
- let cached = this.#readCache.get(absPath);
130
+ let cached = this.#factsCache.get(absPath);
83
131
  if (!cached) {
84
- cached = Bun.file(absPath)
85
- .text()
86
- .catch(() => null);
87
- this.#readCache.set(absPath, cached);
132
+ cached = (async () => {
133
+ const content = await Bun.file(absPath)
134
+ .text()
135
+ .catch(() => null);
136
+ if (content === null) return null;
137
+ // A client entry ends the walk, so its imports are never needed.
138
+ if (USE_CLIENT_RE.test(content)) return { isClientEntry: true, imports: [] };
139
+ return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content)) };
140
+ })();
141
+ this.#factsCache.set(absPath, cached);
88
142
  }
89
143
  return cached;
90
144
  }
91
145
 
146
+ async #rewrite(content: string): Promise<string> {
147
+ if (this.#akanConfig.barrelImports.length === 0) return content;
148
+ try {
149
+ return (await rewriteBarrelImports(content, this.#akanConfig.barrelImports, this.#analyzer)) ?? content;
150
+ } catch {
151
+ return content;
152
+ }
153
+ }
154
+
155
+ #scanImports(source: string): ScannedImport[] {
156
+ try {
157
+ return this.#tsTranspiler.scanImports(source);
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
92
163
  async #resolveFileCandidate(absPathNoExt: string): Promise<string | null> {
93
164
  const cacheKey = path.resolve(absPathNoExt);
94
165
  let cached = this.#resolvedFileCache.get(cacheKey);
@@ -103,6 +174,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
103
174
  const f = path.join(cacheKey, `index${ext}`);
104
175
  if (await this.#fileExists(f)) return f;
105
176
  }
177
+ this.#unresolvedPaths.add(cacheKey);
106
178
  return null;
107
179
  })();
108
180
  this.#resolvedFileCache.set(cacheKey, cached);
@@ -120,45 +192,13 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
120
192
  }
121
193
  const pkg = await this.#resolvePackage(spec);
122
194
  if (pkg) return pkg.entryFile;
195
+ this.#unresolvedSpecifiers.add(cacheKey);
123
196
  return null;
124
197
  })();
125
198
  this.#resolvedSpecifierCache.set(cacheKey, cached);
126
199
  return cached;
127
200
  }
128
201
 
129
- async #getRewrittenSource(file: string, content: string): Promise<string> {
130
- const absPath = path.resolve(file);
131
- let cached = this.#rewriteCache.get(absPath);
132
- if (!cached) {
133
- cached = (async () => {
134
- if (this.#akanConfig.barrelImports.length === 0) return content;
135
- try {
136
- return (await rewriteBarrelImports(content, this.#akanConfig.barrelImports, this.#analyzer)) ?? content;
137
- } catch {
138
- return content;
139
- }
140
- })();
141
- this.#rewriteCache.set(absPath, cached);
142
- }
143
- return cached;
144
- }
145
-
146
- async #getImports(file: string, source: string): Promise<ScannedImport[]> {
147
- const absPath = path.resolve(file);
148
- let cached = this.#importCache.get(absPath);
149
- if (!cached) {
150
- cached = Promise.resolve().then(() => {
151
- try {
152
- return this.#tsTranspiler.scanImports(source);
153
- } catch {
154
- return [];
155
- }
156
- });
157
- this.#importCache.set(absPath, cached);
158
- }
159
- return cached;
160
- }
161
-
162
202
  async #discoverFromFile(file: string, visiting: Set<string>): Promise<Set<string>> {
163
203
  const absPath = path.resolve(file);
164
204
  const cached = this.#reachableEntriesCache.get(absPath);
@@ -167,18 +207,16 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
167
207
 
168
208
  visiting.add(absPath);
169
209
  const entries = new Set<string>();
170
- const content = await this.#readFile(absPath);
171
- if (content === null) return this.#finishDiscovery(absPath, visiting, entries);
210
+ const facts = await this.#facts(absPath);
211
+ if (!facts) return this.#finishDiscovery(absPath, visiting, entries);
172
212
 
173
- if (USE_CLIENT_RE.test(content)) {
213
+ if (facts.isClientEntry) {
174
214
  entries.add(absPath);
175
215
  return this.#finishDiscovery(absPath, visiting, entries);
176
216
  }
177
217
 
178
- const source = await this.#getRewrittenSource(absPath, content);
179
- const imports = await this.#getImports(absPath, source);
180
218
  const importerDir = path.dirname(absPath);
181
- for (const imp of imports) {
219
+ for (const imp of facts.imports) {
182
220
  const spec = imp.path;
183
221
  if (!spec) continue;
184
222
  if (NON_SOURCE_EXT_RE.test(spec)) continue;
@@ -0,0 +1,109 @@
1
+ import { stat } from "node:fs/promises";
2
+
3
+ /** Every identifier-ish token in a source file is a potential tailwind class. */
4
+ const CANDIDATE_RE = /-?[\w@][\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
5
+
6
+ interface CachedFile {
7
+ mtimeMs: number;
8
+ size: number;
9
+ candidates: string[];
10
+ }
11
+
12
+ interface CacheFile {
13
+ version: number;
14
+ files: Record<string, CachedFile>;
15
+ }
16
+
17
+ /**
18
+ * Tailwind candidate tokens per source file, cached on disk across builds.
19
+ *
20
+ * The scan reads the **full text** of every source file on every CSS rebuild — measured at 385-508ms
21
+ * per save on `apps/akan`. Phase 2 moved css compilation into a per-generation batch worker, so an
22
+ * in-memory cache (what `03-phase3-topology-and-trim.md` §3.4 originally proposed) buys nothing: the
23
+ * process that would hold it exits before the next save. Disk is what survives the worker, a builder
24
+ * recycle and a dev-host restart alike, which is the same reasoning §3.3 used for the font cache.
25
+ *
26
+ * Keyed on (mtime, size) per file, so a save re-reads only the files in that batch.
27
+ */
28
+ export class CssCandidateCache {
29
+ /** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
30
+ static readonly #version = 1;
31
+ readonly #path: string;
32
+ readonly #entries = new Map<string, CachedFile>();
33
+ #dirty = false;
34
+ #reused = 0;
35
+ #rescanned = 0;
36
+
37
+ constructor(cachePath: string) {
38
+ this.#path = cachePath;
39
+ }
40
+
41
+ get reused(): number {
42
+ return this.#reused;
43
+ }
44
+
45
+ get rescanned(): number {
46
+ return this.#rescanned;
47
+ }
48
+
49
+ /** A cache that cannot be read or is a version behind simply starts empty — it is only an optimisation. */
50
+ async load(): Promise<this> {
51
+ const raw = (await Bun.file(this.#path)
52
+ .json()
53
+ .catch(() => null)) as CacheFile | null;
54
+ if (!raw || raw.version !== CssCandidateCache.#version || typeof raw.files !== "object") return this;
55
+ for (const [file, entry] of Object.entries(raw.files)) {
56
+ if (typeof entry?.mtimeMs !== "number" || !Array.isArray(entry.candidates)) continue;
57
+ this.#entries.set(file, entry);
58
+ }
59
+ return this;
60
+ }
61
+
62
+ /**
63
+ * The file's candidate tokens, read from disk only when its (mtime, size) no longer matches.
64
+ *
65
+ * Read errors propagate, as they did before this cache existed: a source file the compiler cannot
66
+ * read is a broken build, not a cache miss to paper over.
67
+ */
68
+ async candidatesFor(file: string): Promise<string[]> {
69
+ const stats = await stat(file).catch(() => null);
70
+ const cached = this.#entries.get(file);
71
+ if (stats && cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
72
+ this.#reused += 1;
73
+ return cached.candidates;
74
+ }
75
+ const content = await Bun.file(file).text();
76
+ const candidates = [...new Set(Array.from(content.matchAll(CANDIDATE_RE), (m) => m[0]))];
77
+ this.#rescanned += 1;
78
+ if (stats) {
79
+ this.#entries.set(file, { mtimeMs: stats.mtimeMs, size: stats.size, candidates });
80
+ this.#dirty = true;
81
+ }
82
+ return candidates;
83
+ }
84
+
85
+ /**
86
+ * Persist, dropping files the scan no longer reaches so a renamed or deleted module does not keep
87
+ * feeding its classes to the compiler forever.
88
+ *
89
+ * A rebuild that re-read nothing writes nothing: the boot double-build and any CSS rebuild triggered
90
+ * by something other than a source edit would otherwise rewrite the whole file for no change.
91
+ */
92
+ async save(present: Set<string>): Promise<void> {
93
+ for (const file of [...this.#entries.keys()]) {
94
+ if (present.has(file)) continue;
95
+ this.#entries.delete(file);
96
+ this.#dirty = true;
97
+ }
98
+ if (!this.#dirty) return;
99
+ this.#dirty = false;
100
+ const files: Record<string, CachedFile> = {};
101
+ for (const [file, entry] of this.#entries) files[file] = entry;
102
+ // A cache that cannot be written is a slow build, not a failed one — a read-only checkout must
103
+ // still compile.
104
+ await Bun.write(
105
+ this.#path,
106
+ JSON.stringify({ version: CssCandidateCache.#version, files } satisfies CacheFile),
107
+ ).catch(() => undefined);
108
+ }
109
+ }
@@ -4,6 +4,7 @@ import { compile } from "tailwindcss";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
6
6
  import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
7
+ import { CssCandidateCache } from "./cssCandidateCache";
7
8
  import { CssImportResolver } from "./cssImportResolver";
8
9
 
9
10
  const SOURCE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"] as const;
@@ -25,8 +26,54 @@ export class CssCompiler {
25
26
  this.#app = app;
26
27
  }
27
28
 
29
+ /**
30
+ * Beside the sources rather than in `dist`, because the cache is keyed on source mtimes: a build and
31
+ * the dev server describe the same files, so they should share it rather than each pay the first scan.
32
+ */
33
+ get #candidateCachePath() {
34
+ return path.join(this.#app.cwdPath, ".akan/cache/cssCandidates.json");
35
+ }
36
+
28
37
  #cssText: string | null = null;
29
38
  #cssTextByBasePath: Record<string, string> | null = null;
39
+ /**
40
+ * Import resolution memoised for the life of one compiler, which is one rebuild.
41
+ *
42
+ * The discovery BFS resolves the same specifier from every directory that imports it, and each miss
43
+ * costs up to 13 sequential `exists()` calls — the bare path, six extensions, six `index.*`. Nothing
44
+ * here outlives the rebuild, so there is no invalidation to get wrong.
45
+ */
46
+ #fileExistsCache = new Map<string, Promise<boolean>>();
47
+ #resolvedFileCache = new Map<string, Promise<string | null>>();
48
+ #resolvedSpecifierCache = new Map<string, Promise<string | null>>();
49
+
50
+ #fileExists(absPath: string): Promise<boolean> {
51
+ let cached = this.#fileExistsCache.get(absPath);
52
+ if (!cached) {
53
+ cached = Bun.file(absPath).exists();
54
+ this.#fileExistsCache.set(absPath, cached);
55
+ }
56
+ return cached;
57
+ }
58
+
59
+ #resolveSourceFileCandidate(absPathNoExt: string): Promise<string | null> {
60
+ let cached = this.#resolvedFileCache.get(absPathNoExt);
61
+ if (cached) return cached;
62
+ cached = (async () => {
63
+ if (await this.#fileExists(absPathNoExt)) return isSourceFile(absPathNoExt) ? absPathNoExt : null;
64
+ for (const ext of SOURCE_EXTS) {
65
+ const filePath = `${absPathNoExt}${ext}`;
66
+ if (await this.#fileExists(filePath)) return filePath;
67
+ }
68
+ for (const ext of SOURCE_EXTS) {
69
+ const filePath = path.join(absPathNoExt, `index${ext}`);
70
+ if (await this.#fileExists(filePath)) return filePath;
71
+ }
72
+ return null;
73
+ })();
74
+ this.#resolvedFileCache.set(absPathNoExt, cached);
75
+ return cached;
76
+ }
30
77
  async getCss({ refresh }: { refresh?: boolean } = {}) {
31
78
  if (this.#cssText !== null && !refresh) return this.#cssText;
32
79
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
@@ -197,14 +244,31 @@ export class CssCompiler {
197
244
  const mod = await import(p);
198
245
  return { path: p, base: path.dirname(p), module: mod.default ?? mod };
199
246
  }
200
- async #resolveSourceImport(
247
+ #resolveSourceImport(
248
+ id: string,
249
+ fromBase: string,
250
+ resolvePackage: Awaited<ReturnType<typeof createTsconfigPackageResolver>>,
251
+ ): Promise<string | null> {
252
+ // Keyed by importer directory even for bare specifiers: the tsconfig resolver is
253
+ // directory-independent, but the `Bun.resolveSync` / `require.resolve` fallbacks below are not.
254
+ // The expensive part — the `exists()` probes — is deduplicated by absolute path instead, which is
255
+ // shared across every importer.
256
+ const cacheKey = `${fromBase}\0${id}`;
257
+ let cached = this.#resolvedSpecifierCache.get(cacheKey);
258
+ if (cached) return cached;
259
+ cached = this.#resolveSourceImportUncached(id, fromBase, resolvePackage);
260
+ this.#resolvedSpecifierCache.set(cacheKey, cached);
261
+ return cached;
262
+ }
263
+
264
+ async #resolveSourceImportUncached(
201
265
  id: string,
202
266
  fromBase: string,
203
267
  resolvePackage: Awaited<ReturnType<typeof createTsconfigPackageResolver>>,
204
268
  ): Promise<string | null> {
205
269
  if (id.startsWith(".") || id.startsWith("/")) {
206
270
  const abs = id.startsWith("/") ? id : path.resolve(fromBase, id);
207
- return resolveSourceFileCandidate(abs);
271
+ return this.#resolveSourceFileCandidate(abs);
208
272
  }
209
273
 
210
274
  const pkg = await resolvePackage(id);
@@ -217,7 +281,6 @@ export class CssCompiler {
217
281
  return null;
218
282
  }
219
283
  async #scanCandidates(sourcePaths: string[], dirs: string[]): Promise<string[]> {
220
- const CANDIDATE_RE = /-?[\w@][\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
221
284
  const candidates = new Set<string>();
222
285
  const glob = new Bun.Glob("**/*.{tsx,ts,jsx,js,html}");
223
286
  const files = new Set<string>(sourcePaths);
@@ -229,29 +292,18 @@ export class CssCompiler {
229
292
  }
230
293
  }),
231
294
  );
295
+ const cache = await new CssCandidateCache(this.#candidateCachePath).load();
232
296
  await Promise.all(
233
297
  [...files].map(async (file) => {
234
- const content = await Bun.file(file).text();
235
- for (const m of content.matchAll(CANDIDATE_RE)) candidates.add(m[0]);
298
+ for (const candidate of await cache.candidatesFor(file)) candidates.add(candidate);
236
299
  }),
237
300
  );
301
+ await cache.save(files);
302
+ this.#logger.verbose(`css candidate cache reused=${cache.reused} rescanned=${cache.rescanned}`);
238
303
  return [...candidates];
239
304
  }
240
305
  }
241
306
 
242
- async function resolveSourceFileCandidate(absPathNoExt: string): Promise<string | null> {
243
- if (await Bun.file(absPathNoExt).exists()) return isSourceFile(absPathNoExt) ? absPathNoExt : null;
244
- for (const ext of SOURCE_EXTS) {
245
- const filePath = `${absPathNoExt}${ext}`;
246
- if (await Bun.file(filePath).exists()) return filePath;
247
- }
248
- for (const ext of SOURCE_EXTS) {
249
- const filePath = path.join(absPathNoExt, `index${ext}`);
250
- if (await Bun.file(filePath).exists()) return filePath;
251
- }
252
- return null;
253
- }
254
-
255
307
  function resolveSourceWithBun(id: string, fromBase: string): string | null {
256
308
  try {
257
309
  const resolved = Bun.resolveSync(id, fromBase);
@@ -417,6 +417,9 @@ describe("CssCompiler", () => {
417
417
 
418
418
  const compiler = new CssCompiler({
419
419
  workspace: { workspaceRoot: root },
420
+ // The candidate cache lands under `<cwdPath>/.akan/cache`, so point it at this test's temp root
421
+ // rather than wherever the suite happens to run from.
422
+ cwdPath: root,
420
423
  getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
421
424
  } as never);
422
425
  const css = await compiler.compileCss([cssPath], []);
@@ -264,10 +264,16 @@ export class HmrWatcher {
264
264
  * Terminates rather than looping: a scan that finds nothing schedules nothing, and the build writes its
265
265
  * artifacts under `.akan/` — which the classifier ignores — while codegen writes are content-guarded,
266
266
  * so a rebuild does not move a tracked mtime.
267
+ *
268
+ * The one case that does schedule another is a directory the index could not date reliably (Linux stamps
269
+ * directory mtimes on a 1ms clock, see `SourceMtimeIndex`). That still terminates: the next scan is
270
+ * `verifyDelayMs` later, by which point the timestamp has settled unless something is writing to that
271
+ * directory right now — in which case looking again is the right answer anyway.
267
272
  */
268
273
  async #verify(): Promise<void> {
269
274
  if (this.#stopped || this.#flushing) return;
270
275
  await this.#mergeDetectedChanges();
271
276
  if (this.#pending.size > 0) await this.#drain();
277
+ else if (this.#index.hasUnsettledDirs) this.#scheduleVerify();
272
278
  }
273
279
  }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { chmod, mkdir, mkdtemp, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { SourceMtimeIndex } from "./sourceMtimeIndex";
@@ -100,6 +100,51 @@ describe("SourceMtimeIndex", () => {
100
100
  expect(await index.collectChanges()).toEqual([]);
101
101
  });
102
102
 
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
+ */
113
+ test("finds a created directory even when the clock never moves the parent's mtime", async () => {
114
+ const root = await makeRoot();
115
+ await seed(root, "lib/a.ts");
116
+ 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.
119
+ const frozen = new Date();
120
+ await utimes(dir, frozen, frozen);
121
+ const index = new SourceMtimeIndex({ roots: [root], dirSettleMs: 60_000 });
122
+ await index.prime();
123
+
124
+ const created = await seed(root, "lib/user/user.constant.ts");
125
+ await utimes(dir, frozen, frozen);
126
+ expect((await stat(dir)).mtimeMs).toBe(frozen.getTime());
127
+
128
+ expect(index.hasUnsettledDirs).toBe(true);
129
+ expect(await index.collectChanges()).toEqual([created]);
130
+ });
131
+
132
+ test("stops re-reading a directory once its mtime is old enough to trust", async () => {
133
+ const root = await makeRoot();
134
+ await seed(root, "lib/a.ts");
135
+ const index = new SourceMtimeIndex({ roots: [root], dirSettleMs: 60_000 });
136
+ await index.prime();
137
+ expect(index.hasUnsettledDirs).toBe(true);
138
+
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
+ const settled = new Date(Date.now() - 120_000);
142
+ for (const dir of [root, path.join(root, "lib")]) await utimes(dir, settled, settled);
143
+
144
+ expect(await index.collectChanges()).toEqual([]);
145
+ expect(index.hasUnsettledDirs).toBe(false);
146
+ });
147
+
103
148
  test("reports a deleted file and a deleted directory's files", async () => {
104
149
  const root = await makeRoot();
105
150
  const kept = await seed(root, "lib/a.ts");
@@ -256,7 +301,11 @@ describe("SourceMtimeIndex", () => {
256
301
  // goes unreported.
257
302
  expect(await index.collectChanges()).toEqual([]);
258
303
  expect(index.trackedFileCount).toBe(1);
259
- expect(index.coverageGaps.map((gap) => gap.code)).toEqual(["EACCES"]);
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.
307
+ expect(index.coverageGaps.map((gap) => gap.path)).toContain(abs);
308
+ expect(index.coverageGaps.every((gap) => gap.code === "EACCES")).toBe(true);
260
309
 
261
310
  await chmod(locked, 0o755);
262
311
  await rewrite(abs, "1234");