@react-native-harness/cache 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +26 -0
  3. package/dist/__tests__/boundary.test.d.ts +2 -0
  4. package/dist/__tests__/boundary.test.d.ts.map +1 -0
  5. package/dist/__tests__/boundary.test.js +77 -0
  6. package/dist/__tests__/domains/metro.test.d.ts +2 -0
  7. package/dist/__tests__/domains/metro.test.d.ts.map +1 -0
  8. package/dist/__tests__/domains/metro.test.js +234 -0
  9. package/dist/__tests__/index.test.d.ts +2 -0
  10. package/dist/__tests__/index.test.d.ts.map +1 -0
  11. package/dist/__tests__/index.test.js +40 -0
  12. package/dist/__tests__/paths.test.d.ts +2 -0
  13. package/dist/__tests__/paths.test.d.ts.map +1 -0
  14. package/dist/__tests__/paths.test.js +12 -0
  15. package/dist/__tests__/plan.test.d.ts +2 -0
  16. package/dist/__tests__/plan.test.d.ts.map +1 -0
  17. package/dist/__tests__/plan.test.js +197 -0
  18. package/dist/__tests__/warmth.test.d.ts +2 -0
  19. package/dist/__tests__/warmth.test.d.ts.map +1 -0
  20. package/dist/__tests__/warmth.test.js +36 -0
  21. package/dist/domains/metro.d.ts +8 -0
  22. package/dist/domains/metro.d.ts.map +1 -0
  23. package/dist/domains/metro.js +151 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +29 -0
  27. package/dist/paths.d.ts +14 -0
  28. package/dist/paths.d.ts.map +1 -0
  29. package/dist/paths.js +26 -0
  30. package/dist/plan.d.ts +54 -0
  31. package/dist/plan.d.ts.map +1 -0
  32. package/dist/plan.js +177 -0
  33. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  34. package/dist/warmth.d.ts +8 -0
  35. package/dist/warmth.d.ts.map +1 -0
  36. package/dist/warmth.js +40 -0
  37. package/eslint.config.mjs +20 -0
  38. package/package.json +22 -0
  39. package/src/__tests__/boundary.test.ts +95 -0
  40. package/src/__tests__/domains/metro.test.ts +298 -0
  41. package/src/__tests__/index.test.ts +54 -0
  42. package/src/__tests__/paths.test.ts +18 -0
  43. package/src/__tests__/plan.test.ts +330 -0
  44. package/src/__tests__/warmth.test.ts +47 -0
  45. package/src/domains/metro.ts +202 -0
  46. package/src/index.ts +84 -0
  47. package/src/paths.ts +43 -0
  48. package/src/plan.ts +285 -0
  49. package/src/warmth.ts +59 -0
  50. package/tsconfig.json +13 -0
  51. package/tsconfig.lib.json +18 -0
  52. package/vite.config.ts +18 -0
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { createHarnessCache } from '../index.js';
6
+
7
+ describe('createHarnessCache', () => {
8
+ let projectRoot: string;
9
+
10
+ beforeEach(() => {
11
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(projectRoot, { recursive: true, force: true });
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ it('exposes paths rooted at <projectRoot>/.harness/cache', () => {
20
+ const cache = createHarnessCache({ projectRoot });
21
+
22
+ expect(cache.paths.root).toBe(path.join(projectRoot, '.harness', 'cache'));
23
+ });
24
+
25
+ it('reports metro as cold before directories exist', () => {
26
+ const cache = createHarnessCache({ projectRoot });
27
+
28
+ expect(cache.isWarm('metro')).toBe(false);
29
+ });
30
+
31
+ it('creates domain directories and reports warm once populated', () => {
32
+ const cache = createHarnessCache({ projectRoot });
33
+
34
+ cache.ensureDomainDirectories('metro');
35
+
36
+ expect(fs.existsSync(cache.paths.metroTransform)).toBe(true);
37
+ expect(fs.existsSync(cache.paths.metroFileMap)).toBe(true);
38
+ expect(cache.isWarm('metro')).toBe(false);
39
+
40
+ fs.writeFileSync(path.join(cache.paths.metroTransform, 'entry'), 'data');
41
+ expect(cache.isWarm('metro')).toBe(true);
42
+ });
43
+
44
+ it('swallows and logs errors instead of throwing when directories cannot be created', () => {
45
+ // Point the project root at a path that cannot be created (its parent is a file).
46
+ const blockedRoot = path.join(projectRoot, 'blocked-file', 'nested');
47
+ fs.writeFileSync(path.join(projectRoot, 'blocked-file'), 'not a directory');
48
+
49
+ const cache = createHarnessCache({ projectRoot: blockedRoot });
50
+
51
+ expect(() => cache.ensureDomainDirectories('metro')).not.toThrow();
52
+ expect(cache.isWarm('metro')).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,18 @@
1
+ import path from 'node:path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createHarnessCachePaths } from '../paths.js';
4
+
5
+ describe('createHarnessCachePaths', () => {
6
+ it('nests cache paths under <projectRoot>/.harness/cache', () => {
7
+ const projectRoot = '/tmp/some-project';
8
+ const paths = createHarnessCachePaths(projectRoot);
9
+
10
+ expect(paths.root).toBe(path.join(projectRoot, '.harness', 'cache'));
11
+ expect(paths.metroTransform).toBe(
12
+ path.join(projectRoot, '.harness', 'cache', 'metro')
13
+ );
14
+ expect(paths.metroFileMap).toBe(
15
+ path.join(projectRoot, '.harness', 'cache', 'metro-file-map')
16
+ );
17
+ });
18
+ });
@@ -0,0 +1,330 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { createHarnessCachePaths } from '../paths.js';
6
+ import {
7
+ planRestore,
8
+ planSave,
9
+ snapshot,
10
+ writeKeysFile,
11
+ type CacheKeyInputs,
12
+ type CacheSnapshot,
13
+ } from '../plan.js';
14
+
15
+ describe('planRestore', () => {
16
+ let projectRoot: string;
17
+
18
+ beforeEach(() => {
19
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
20
+ });
21
+
22
+ afterEach(() => {
23
+ fs.rmSync(projectRoot, { recursive: true, force: true });
24
+ });
25
+
26
+ const inputs = (
27
+ staticInputs: Record<string, string>
28
+ ): Record<'metro', CacheKeyInputs> => ({
29
+ metro: { os: 'linux', staticInputs },
30
+ });
31
+
32
+ it('produces the same restoreKey across independent calls given the same staticInputs', () => {
33
+ const paths = createHarnessCachePaths(projectRoot);
34
+
35
+ const first = planRestore(paths, inputs({ a: '1', b: '2' }));
36
+ const second = planRestore(paths, inputs({ a: '1', b: '2' }));
37
+
38
+ expect(first.domains.metro?.restoreKey).toBe(
39
+ second.domains.metro?.restoreKey
40
+ );
41
+ });
42
+
43
+ it('produces the same restoreKey regardless of staticInputs key order', () => {
44
+ const paths = createHarnessCachePaths(projectRoot);
45
+
46
+ const first = planRestore(paths, inputs({ a: '1', b: '2' }));
47
+ const second = planRestore(paths, inputs({ b: '2', a: '1' }));
48
+
49
+ expect(first.domains.metro?.restoreKey).toBe(
50
+ second.domains.metro?.restoreKey
51
+ );
52
+ });
53
+
54
+ it('produces a different restoreKey for different staticInputs', () => {
55
+ const paths = createHarnessCachePaths(projectRoot);
56
+
57
+ const first = planRestore(paths, inputs({ a: '1' }));
58
+ const second = planRestore(paths, inputs({ a: '2' }));
59
+
60
+ expect(first.domains.metro?.restoreKey).not.toBe(
61
+ second.domains.metro?.restoreKey
62
+ );
63
+ });
64
+
65
+ it('shapes restorePrefixes for the accretive metro domain as a single trailing-dash prefix', () => {
66
+ const paths = createHarnessCachePaths(projectRoot);
67
+ const plan = planRestore(paths, inputs({ a: '1' }));
68
+ const domain = plan.domains.metro;
69
+
70
+ expect(domain?.accretive).toBe(true);
71
+ expect(domain?.restorePrefixes).toEqual([`${domain?.restoreKey}-`]);
72
+ });
73
+
74
+ it('only includes domains present in the inputs', () => {
75
+ const paths = createHarnessCachePaths(projectRoot);
76
+ const plan = planRestore(paths, {});
77
+
78
+ expect(plan.domains).toEqual({});
79
+ });
80
+
81
+ it('reports the domain cache directories as paths', () => {
82
+ const paths = createHarnessCachePaths(projectRoot);
83
+ const plan = planRestore(paths, inputs({ a: '1' }));
84
+
85
+ expect(plan.domains.metro?.paths).toEqual([
86
+ paths.metroTransform,
87
+ paths.metroFileMap,
88
+ ]);
89
+ });
90
+ });
91
+
92
+ describe('snapshot', () => {
93
+ let projectRoot: string;
94
+
95
+ beforeEach(() => {
96
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
97
+ });
98
+
99
+ afterEach(() => {
100
+ fs.rmSync(projectRoot, { recursive: true, force: true });
101
+ });
102
+
103
+ it('is empty for a domain whose directories do not exist', () => {
104
+ const paths = createHarnessCachePaths(projectRoot);
105
+
106
+ expect(() => snapshot(paths)).not.toThrow();
107
+ expect(snapshot(paths).metro).toEqual([]);
108
+ });
109
+
110
+ it('collects entries for files under the domain directories', () => {
111
+ const paths = createHarnessCachePaths(projectRoot);
112
+ fs.mkdirSync(path.join(paths.metroTransform, 'nested'), {
113
+ recursive: true,
114
+ });
115
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
116
+ fs.writeFileSync(
117
+ path.join(paths.metroTransform, 'nested', 'b.txt'),
118
+ 'bb'
119
+ );
120
+
121
+ const result = snapshot(paths);
122
+ const entries = result.metro ?? [];
123
+
124
+ expect(entries).toHaveLength(2);
125
+ const aEntry = entries.find((entry) =>
126
+ entry.path.endsWith(path.join('metro', 'a.txt'))
127
+ );
128
+ const bEntry = entries.find((entry) =>
129
+ entry.path.endsWith(path.join('metro', 'nested', 'b.txt'))
130
+ );
131
+ expect(aEntry?.sizeBytes).toBe(3);
132
+ expect(bEntry?.sizeBytes).toBe(2);
133
+ expect(typeof aEntry?.mtimeMs).toBe('number');
134
+ });
135
+ });
136
+
137
+ describe('planSave', () => {
138
+ let projectRoot: string;
139
+
140
+ beforeEach(() => {
141
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
142
+ });
143
+
144
+ afterEach(() => {
145
+ fs.rmSync(projectRoot, { recursive: true, force: true });
146
+ });
147
+
148
+ const inputs = (
149
+ staticInputs: Record<string, string> = { a: '1' }
150
+ ): Record<'metro', CacheKeyInputs> => ({
151
+ metro: { os: 'linux', staticInputs },
152
+ });
153
+
154
+ it('does not save when there is no change, regardless of policy', () => {
155
+ const paths = createHarnessCachePaths(projectRoot);
156
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
157
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
158
+
159
+ const before = snapshot(paths);
160
+ const plan = planSave(
161
+ paths,
162
+ before,
163
+ { mode: 'always', isDefaultBranch: true },
164
+ inputs()
165
+ );
166
+
167
+ expect(plan.domains.metro?.shouldSave).toBe(false);
168
+ expect(plan.domains.metro?.delta).toEqual({
169
+ addedEntries: 0,
170
+ removedEntries: 0,
171
+ addedBytes: 0,
172
+ });
173
+ });
174
+
175
+ it('saves an added file only when policy allows it', () => {
176
+ const paths = createHarnessCachePaths(projectRoot);
177
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
178
+ const before = snapshot(paths);
179
+
180
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
181
+
182
+ const disallowed = planSave(
183
+ paths,
184
+ before,
185
+ { mode: 'default-branch', isDefaultBranch: false },
186
+ inputs()
187
+ );
188
+ expect(disallowed.domains.metro?.shouldSave).toBe(false);
189
+ expect(disallowed.domains.metro?.delta.addedEntries).toBe(1);
190
+ expect(disallowed.domains.metro?.delta.addedBytes).toBe(3);
191
+
192
+ const allowed = planSave(
193
+ paths,
194
+ before,
195
+ { mode: 'default-branch', isDefaultBranch: true },
196
+ inputs()
197
+ );
198
+ expect(allowed.domains.metro?.shouldSave).toBe(true);
199
+ });
200
+
201
+ it('never saves when policy mode is "never"', () => {
202
+ const paths = createHarnessCachePaths(projectRoot);
203
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
204
+ const before = snapshot(paths);
205
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
206
+
207
+ const plan = planSave(
208
+ paths,
209
+ before,
210
+ { mode: 'never', isDefaultBranch: true },
211
+ inputs()
212
+ );
213
+
214
+ expect(plan.domains.metro?.shouldSave).toBe(false);
215
+ });
216
+
217
+ it('counts a removed file in the delta', () => {
218
+ const paths = createHarnessCachePaths(projectRoot);
219
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
220
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
221
+ const before = snapshot(paths);
222
+
223
+ fs.rmSync(path.join(paths.metroTransform, 'a.txt'));
224
+
225
+ const plan = planSave(
226
+ paths,
227
+ before,
228
+ { mode: 'always', isDefaultBranch: true },
229
+ inputs()
230
+ );
231
+
232
+ expect(plan.domains.metro?.delta.removedEntries).toBe(1);
233
+ expect(plan.domains.metro?.shouldSave).toBe(true);
234
+ });
235
+
236
+ it('does not treat an mtime-only change as an added entry or change the content hash', () => {
237
+ const paths = createHarnessCachePaths(projectRoot);
238
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
239
+ const filePath = path.join(paths.metroTransform, 'a.txt');
240
+ fs.writeFileSync(filePath, 'aaa');
241
+ const before = snapshot(paths);
242
+
243
+ const beforeSave = planSave(
244
+ paths,
245
+ before,
246
+ { mode: 'always', isDefaultBranch: true },
247
+ inputs()
248
+ );
249
+
250
+ // Touch the file's mtime without changing its size/content.
251
+ const future = new Date(Date.now() + 10_000);
252
+ fs.utimesSync(filePath, future, future);
253
+
254
+ const afterSave = planSave(
255
+ paths,
256
+ before,
257
+ { mode: 'always', isDefaultBranch: true },
258
+ inputs()
259
+ );
260
+
261
+ expect(afterSave.domains.metro?.delta.addedEntries).toBe(0);
262
+ expect(afterSave.domains.metro?.shouldSave).toBe(false);
263
+ expect(afterSave.domains.metro?.saveKey).toBe(
264
+ beforeSave.domains.metro?.saveKey
265
+ );
266
+ });
267
+
268
+ it('produces a deterministic saveKey across independent planSave calls given the same on-disk state', () => {
269
+ const paths = createHarnessCachePaths(projectRoot);
270
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
271
+ fs.writeFileSync(path.join(paths.metroTransform, 'a.txt'), 'aaa');
272
+ const before: CacheSnapshot = {};
273
+
274
+ const first = planSave(
275
+ paths,
276
+ before,
277
+ { mode: 'always', isDefaultBranch: true },
278
+ inputs()
279
+ );
280
+ const second = planSave(
281
+ paths,
282
+ before,
283
+ { mode: 'always', isDefaultBranch: true },
284
+ inputs()
285
+ );
286
+
287
+ expect(first.domains.metro?.saveKey).toBe(second.domains.metro?.saveKey);
288
+ });
289
+ });
290
+
291
+ describe('writeKeysFile', () => {
292
+ let projectRoot: string;
293
+
294
+ beforeEach(() => {
295
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
296
+ });
297
+
298
+ afterEach(() => {
299
+ fs.rmSync(projectRoot, { recursive: true, force: true });
300
+ });
301
+
302
+ it('writes keys.json under the cache root, outside domain subdirectories', () => {
303
+ const paths = createHarnessCachePaths(projectRoot);
304
+ const plan = planRestore(paths, {
305
+ metro: { os: 'linux', staticInputs: { a: '1' } },
306
+ });
307
+
308
+ writeKeysFile(paths, plan);
309
+
310
+ const filePath = path.join(paths.root, 'keys.json');
311
+ expect(fs.existsSync(filePath)).toBe(true);
312
+ expect(filePath.startsWith(paths.metroTransform)).toBe(false);
313
+ expect(filePath.startsWith(paths.metroFileMap)).toBe(false);
314
+
315
+ const written = JSON.parse(fs.readFileSync(filePath, 'utf8'));
316
+ expect(written.version).toBe(1);
317
+ });
318
+
319
+ it('swallows errors instead of throwing when the cache root cannot be created', () => {
320
+ const blockedRoot = path.join(projectRoot, 'blocked-file', 'nested');
321
+ fs.writeFileSync(path.join(projectRoot, 'blocked-file'), 'not a directory');
322
+ const paths = createHarnessCachePaths(blockedRoot);
323
+ const plan = planRestore(paths, {
324
+ metro: { os: 'linux', staticInputs: { a: '1' } },
325
+ });
326
+
327
+ expect(() => writeKeysFile(paths, plan)).not.toThrow();
328
+ expect(fs.existsSync(path.join(paths.root, 'keys.json'))).toBe(false);
329
+ });
330
+ });
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { createHarnessCachePaths } from '../paths.js';
6
+ import { isDomainWarm } from '../warmth.js';
7
+
8
+ describe('isDomainWarm', () => {
9
+ let projectRoot: string;
10
+
11
+ beforeEach(() => {
12
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(projectRoot, { recursive: true, force: true });
17
+ });
18
+
19
+ it('is false when the domain directory does not exist', () => {
20
+ const paths = createHarnessCachePaths(projectRoot);
21
+
22
+ expect(isDomainWarm('metro', paths)).toBe(false);
23
+ });
24
+
25
+ it('is false when the domain directory exists but is empty', () => {
26
+ const paths = createHarnessCachePaths(projectRoot);
27
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
28
+
29
+ expect(isDomainWarm('metro', paths)).toBe(false);
30
+ });
31
+
32
+ it('is true when the domain directory exists and has entries', () => {
33
+ const paths = createHarnessCachePaths(projectRoot);
34
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
35
+ fs.writeFileSync(path.join(paths.metroTransform, 'entry'), 'data');
36
+
37
+ expect(isDomainWarm('metro', paths)).toBe(true);
38
+ });
39
+
40
+ it('is false when the path exists but is a file, not a directory', () => {
41
+ const paths = createHarnessCachePaths(projectRoot);
42
+ fs.mkdirSync(path.dirname(paths.metroTransform), { recursive: true });
43
+ fs.writeFileSync(paths.metroTransform, 'not a directory');
44
+
45
+ expect(isDomainWarm('metro', paths)).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,202 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { logger } from '@react-native-harness/tools';
5
+
6
+ const cacheLogger = logger.child('cache');
7
+
8
+ // Metro resolves its own config by walking UP from the project root (see
9
+ // Metro's loadConfig docs), never by scanning the tree downward. Lockfiles in
10
+ // a monorepo live at the repo root, i.e. somewhere on projectRoot's ancestor
11
+ // chain. So instead of a downward `hashFiles('**/…')`-style walk (which also
12
+ // has to dodge node_modules/.git/dist/build to avoid vendored copies), we
13
+ // walk upward from projectRoot to repoRoot (inclusive) and hash every
14
+ // candidate key file that exists along that chain. Sibling workspace
15
+ // packages are never on this chain, so their configs can't spuriously
16
+ // invalidate this project's cache key.
17
+ const LOCKFILE_NAMES: readonly string[] = [
18
+ 'bun.lock',
19
+ 'bun.lockb',
20
+ 'package-lock.json',
21
+ 'npm-shrinkwrap.json',
22
+ 'pnpm-lock.yaml',
23
+ 'yarn.lock',
24
+ ];
25
+
26
+ // Metro config candidates: `metro.config.*` at the directory root, plus the
27
+ // `.config/metro.*` directory convention Metro also resolves.
28
+ const METRO_CONFIG_EXTENSIONS: readonly string[] = [
29
+ 'js',
30
+ 'cjs',
31
+ 'mjs',
32
+ 'ts',
33
+ 'cts',
34
+ 'mts',
35
+ 'json',
36
+ ];
37
+ const METRO_CONFIG_NAMES: readonly string[] = METRO_CONFIG_EXTENSIONS.map(
38
+ (extension) => `metro.config.${extension}`
39
+ );
40
+ const METRO_CONFIG_DIR_NAMES: readonly string[] = METRO_CONFIG_EXTENSIONS.map(
41
+ (extension) => `.config/metro.${extension}`
42
+ );
43
+
44
+ // Babel only resolves root configs named `babel.config.*` (note: no `.ts` —
45
+ // Babel does not resolve `babel.config.ts`). File-relative `.babelrc` and the
46
+ // `package.json` `babel`/`metro` fields are intentionally out of scope.
47
+ const BABEL_CONFIG_NAMES: readonly string[] = [
48
+ 'babel.config.json',
49
+ 'babel.config.js',
50
+ 'babel.config.cjs',
51
+ 'babel.config.mjs',
52
+ 'babel.config.cts',
53
+ ];
54
+
55
+ // Relative-to-examined-directory paths checked at every directory on the
56
+ // ancestor chain. All candidates that exist are hashed (rather than
57
+ // emulating Metro/Babel's own first-match-wins resolution), which is
58
+ // deterministic and strictly more conservative.
59
+ export const METRO_KEY_FILENAMES: readonly string[] = [
60
+ ...LOCKFILE_NAMES,
61
+ ...METRO_CONFIG_NAMES,
62
+ ...METRO_CONFIG_DIR_NAMES,
63
+ ...BABEL_CONFIG_NAMES,
64
+ ];
65
+
66
+ interface KeyFileMatch {
67
+ relativePath: string;
68
+ content: Buffer;
69
+ }
70
+
71
+ /**
72
+ * Directories to examine: projectRoot and every ancestor directory up to and
73
+ * including repoRoot. Defensive: if projectRoot isn't inside repoRoot (or
74
+ * repoRoot can't be related to it), fall back to examining projectRoot alone
75
+ * rather than guessing.
76
+ */
77
+ const collectAncestorDirectories = (
78
+ projectRoot: string,
79
+ repoRoot: string
80
+ ): string[] => {
81
+ const resolvedProjectRoot = path.resolve(projectRoot);
82
+ const resolvedRepoRoot = path.resolve(repoRoot);
83
+
84
+ if (resolvedProjectRoot === resolvedRepoRoot) {
85
+ return [resolvedProjectRoot];
86
+ }
87
+
88
+ const relative = path.relative(resolvedRepoRoot, resolvedProjectRoot);
89
+ const isProjectRootInsideRepoRoot =
90
+ relative !== '' &&
91
+ !relative.startsWith(`..${path.sep}`) &&
92
+ relative !== '..' &&
93
+ !path.isAbsolute(relative);
94
+
95
+ if (!isProjectRootInsideRepoRoot) {
96
+ return [resolvedProjectRoot];
97
+ }
98
+
99
+ const directories: string[] = [];
100
+ let current = resolvedProjectRoot;
101
+ for (;;) {
102
+ directories.push(current);
103
+ if (current === resolvedRepoRoot) {
104
+ break;
105
+ }
106
+ const parent = path.dirname(current);
107
+ if (parent === current) {
108
+ // Reached the filesystem root without hitting repoRoot; defensive
109
+ // guard against an infinite loop.
110
+ break;
111
+ }
112
+ current = parent;
113
+ }
114
+
115
+ return directories;
116
+ };
117
+
118
+ const collectKeyFiles = (
119
+ directories: readonly string[],
120
+ repoRoot: string
121
+ ): KeyFileMatch[] => {
122
+ const matches: KeyFileMatch[] = [];
123
+
124
+ for (const directory of directories) {
125
+ for (const candidate of METRO_KEY_FILENAMES) {
126
+ const fullPath = path.join(directory, candidate);
127
+
128
+ if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
129
+ continue;
130
+ }
131
+
132
+ matches.push({
133
+ relativePath: path
134
+ .relative(repoRoot, fullPath)
135
+ .split(path.sep)
136
+ .join('/'),
137
+ content: fs.readFileSync(fullPath),
138
+ });
139
+ }
140
+ }
141
+
142
+ return matches;
143
+ };
144
+
145
+ const hashKeyFiles = (matches: KeyFileMatch[]): string => {
146
+ const hash = createHash('sha256');
147
+
148
+ for (const match of matches
149
+ .slice()
150
+ .sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1))) {
151
+ hash.update(match.relativePath);
152
+ hash.update('\0');
153
+ hash.update(match.content);
154
+ hash.update('\0');
155
+ }
156
+
157
+ return hash.digest('hex');
158
+ };
159
+
160
+ export const computeMetroStaticInputs = (options: {
161
+ projectRoot: string;
162
+ repoRoot: string;
163
+ bundlerMetroVersion: string;
164
+ salt?: string;
165
+ }): Record<string, string> => {
166
+ try {
167
+ const resolvedProjectRoot = path.resolve(options.projectRoot);
168
+ const resolvedRepoRoot = path.resolve(options.repoRoot);
169
+
170
+ // Validate both roots exist so a missing directory degrades to the
171
+ // always-miss fallback below instead of silently hashing zero files.
172
+ if (!fs.statSync(resolvedProjectRoot).isDirectory()) {
173
+ throw new Error(
174
+ `projectRoot "${resolvedProjectRoot}" is not a directory`
175
+ );
176
+ }
177
+ if (!fs.statSync(resolvedRepoRoot).isDirectory()) {
178
+ throw new Error(`repoRoot "${resolvedRepoRoot}" is not a directory`);
179
+ }
180
+
181
+ const directories = collectAncestorDirectories(
182
+ resolvedProjectRoot,
183
+ resolvedRepoRoot
184
+ );
185
+ const matches = collectKeyFiles(directories, resolvedRepoRoot);
186
+
187
+ return {
188
+ lockfileHash: hashKeyFiles(matches),
189
+ bundlerMetroVersion: options.bundlerMetroVersion,
190
+ ...(options.salt ? { salt: options.salt } : {}),
191
+ };
192
+ } catch (error) {
193
+ cacheLogger.warn(
194
+ `Failed to compute Metro cache key inputs for "${options.projectRoot}". Falling back to an always-miss key.`,
195
+ error
196
+ );
197
+ return {
198
+ lockfileHash: 'unavailable',
199
+ bundlerMetroVersion: options.bundlerMetroVersion,
200
+ };
201
+ }
202
+ };