@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,36 @@
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
+ describe('isDomainWarm', () => {
8
+ let projectRoot;
9
+ beforeEach(() => {
10
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-'));
11
+ });
12
+ afterEach(() => {
13
+ fs.rmSync(projectRoot, { recursive: true, force: true });
14
+ });
15
+ it('is false when the domain directory does not exist', () => {
16
+ const paths = createHarnessCachePaths(projectRoot);
17
+ expect(isDomainWarm('metro', paths)).toBe(false);
18
+ });
19
+ it('is false when the domain directory exists but is empty', () => {
20
+ const paths = createHarnessCachePaths(projectRoot);
21
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
22
+ expect(isDomainWarm('metro', paths)).toBe(false);
23
+ });
24
+ it('is true when the domain directory exists and has entries', () => {
25
+ const paths = createHarnessCachePaths(projectRoot);
26
+ fs.mkdirSync(paths.metroTransform, { recursive: true });
27
+ fs.writeFileSync(path.join(paths.metroTransform, 'entry'), 'data');
28
+ expect(isDomainWarm('metro', paths)).toBe(true);
29
+ });
30
+ it('is false when the path exists but is a file, not a directory', () => {
31
+ const paths = createHarnessCachePaths(projectRoot);
32
+ fs.mkdirSync(path.dirname(paths.metroTransform), { recursive: true });
33
+ fs.writeFileSync(paths.metroTransform, 'not a directory');
34
+ expect(isDomainWarm('metro', paths)).toBe(false);
35
+ });
36
+ });
@@ -0,0 +1,8 @@
1
+ export declare const METRO_KEY_FILENAMES: readonly string[];
2
+ export declare const computeMetroStaticInputs: (options: {
3
+ projectRoot: string;
4
+ repoRoot: string;
5
+ bundlerMetroVersion: string;
6
+ salt?: string;
7
+ }) => Record<string, string>;
8
+ //# sourceMappingURL=metro.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metro.d.ts","sourceRoot":"","sources":["../../src/domains/metro.ts"],"names":[],"mappings":"AA0DA,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAKhD,CAAC;AAgGF,eAAO,MAAM,wBAAwB,GAAI,SAAS;IAChD,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAqCxB,CAAC"}
@@ -0,0 +1,151 @@
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
+ const cacheLogger = logger.child('cache');
6
+ // Metro resolves its own config by walking UP from the project root (see
7
+ // Metro's loadConfig docs), never by scanning the tree downward. Lockfiles in
8
+ // a monorepo live at the repo root, i.e. somewhere on projectRoot's ancestor
9
+ // chain. So instead of a downward `hashFiles('**/…')`-style walk (which also
10
+ // has to dodge node_modules/.git/dist/build to avoid vendored copies), we
11
+ // walk upward from projectRoot to repoRoot (inclusive) and hash every
12
+ // candidate key file that exists along that chain. Sibling workspace
13
+ // packages are never on this chain, so their configs can't spuriously
14
+ // invalidate this project's cache key.
15
+ const LOCKFILE_NAMES = [
16
+ 'bun.lock',
17
+ 'bun.lockb',
18
+ 'package-lock.json',
19
+ 'npm-shrinkwrap.json',
20
+ 'pnpm-lock.yaml',
21
+ 'yarn.lock',
22
+ ];
23
+ // Metro config candidates: `metro.config.*` at the directory root, plus the
24
+ // `.config/metro.*` directory convention Metro also resolves.
25
+ const METRO_CONFIG_EXTENSIONS = [
26
+ 'js',
27
+ 'cjs',
28
+ 'mjs',
29
+ 'ts',
30
+ 'cts',
31
+ 'mts',
32
+ 'json',
33
+ ];
34
+ const METRO_CONFIG_NAMES = METRO_CONFIG_EXTENSIONS.map((extension) => `metro.config.${extension}`);
35
+ const METRO_CONFIG_DIR_NAMES = METRO_CONFIG_EXTENSIONS.map((extension) => `.config/metro.${extension}`);
36
+ // Babel only resolves root configs named `babel.config.*` (note: no `.ts` —
37
+ // Babel does not resolve `babel.config.ts`). File-relative `.babelrc` and the
38
+ // `package.json` `babel`/`metro` fields are intentionally out of scope.
39
+ const BABEL_CONFIG_NAMES = [
40
+ 'babel.config.json',
41
+ 'babel.config.js',
42
+ 'babel.config.cjs',
43
+ 'babel.config.mjs',
44
+ 'babel.config.cts',
45
+ ];
46
+ // Relative-to-examined-directory paths checked at every directory on the
47
+ // ancestor chain. All candidates that exist are hashed (rather than
48
+ // emulating Metro/Babel's own first-match-wins resolution), which is
49
+ // deterministic and strictly more conservative.
50
+ export const METRO_KEY_FILENAMES = [
51
+ ...LOCKFILE_NAMES,
52
+ ...METRO_CONFIG_NAMES,
53
+ ...METRO_CONFIG_DIR_NAMES,
54
+ ...BABEL_CONFIG_NAMES,
55
+ ];
56
+ /**
57
+ * Directories to examine: projectRoot and every ancestor directory up to and
58
+ * including repoRoot. Defensive: if projectRoot isn't inside repoRoot (or
59
+ * repoRoot can't be related to it), fall back to examining projectRoot alone
60
+ * rather than guessing.
61
+ */
62
+ const collectAncestorDirectories = (projectRoot, repoRoot) => {
63
+ const resolvedProjectRoot = path.resolve(projectRoot);
64
+ const resolvedRepoRoot = path.resolve(repoRoot);
65
+ if (resolvedProjectRoot === resolvedRepoRoot) {
66
+ return [resolvedProjectRoot];
67
+ }
68
+ const relative = path.relative(resolvedRepoRoot, resolvedProjectRoot);
69
+ const isProjectRootInsideRepoRoot = relative !== '' &&
70
+ !relative.startsWith(`..${path.sep}`) &&
71
+ relative !== '..' &&
72
+ !path.isAbsolute(relative);
73
+ if (!isProjectRootInsideRepoRoot) {
74
+ return [resolvedProjectRoot];
75
+ }
76
+ const directories = [];
77
+ let current = resolvedProjectRoot;
78
+ for (;;) {
79
+ directories.push(current);
80
+ if (current === resolvedRepoRoot) {
81
+ break;
82
+ }
83
+ const parent = path.dirname(current);
84
+ if (parent === current) {
85
+ // Reached the filesystem root without hitting repoRoot; defensive
86
+ // guard against an infinite loop.
87
+ break;
88
+ }
89
+ current = parent;
90
+ }
91
+ return directories;
92
+ };
93
+ const collectKeyFiles = (directories, repoRoot) => {
94
+ const matches = [];
95
+ for (const directory of directories) {
96
+ for (const candidate of METRO_KEY_FILENAMES) {
97
+ const fullPath = path.join(directory, candidate);
98
+ if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
99
+ continue;
100
+ }
101
+ matches.push({
102
+ relativePath: path
103
+ .relative(repoRoot, fullPath)
104
+ .split(path.sep)
105
+ .join('/'),
106
+ content: fs.readFileSync(fullPath),
107
+ });
108
+ }
109
+ }
110
+ return matches;
111
+ };
112
+ const hashKeyFiles = (matches) => {
113
+ const hash = createHash('sha256');
114
+ for (const match of matches
115
+ .slice()
116
+ .sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1))) {
117
+ hash.update(match.relativePath);
118
+ hash.update('\0');
119
+ hash.update(match.content);
120
+ hash.update('\0');
121
+ }
122
+ return hash.digest('hex');
123
+ };
124
+ export const computeMetroStaticInputs = (options) => {
125
+ try {
126
+ const resolvedProjectRoot = path.resolve(options.projectRoot);
127
+ const resolvedRepoRoot = path.resolve(options.repoRoot);
128
+ // Validate both roots exist so a missing directory degrades to the
129
+ // always-miss fallback below instead of silently hashing zero files.
130
+ if (!fs.statSync(resolvedProjectRoot).isDirectory()) {
131
+ throw new Error(`projectRoot "${resolvedProjectRoot}" is not a directory`);
132
+ }
133
+ if (!fs.statSync(resolvedRepoRoot).isDirectory()) {
134
+ throw new Error(`repoRoot "${resolvedRepoRoot}" is not a directory`);
135
+ }
136
+ const directories = collectAncestorDirectories(resolvedProjectRoot, resolvedRepoRoot);
137
+ const matches = collectKeyFiles(directories, resolvedRepoRoot);
138
+ return {
139
+ lockfileHash: hashKeyFiles(matches),
140
+ bundlerMetroVersion: options.bundlerMetroVersion,
141
+ ...(options.salt ? { salt: options.salt } : {}),
142
+ };
143
+ }
144
+ catch (error) {
145
+ cacheLogger.warn(`Failed to compute Metro cache key inputs for "${options.projectRoot}". Falling back to an always-miss key.`, error);
146
+ return {
147
+ lockfileHash: 'unavailable',
148
+ bundlerMetroVersion: options.bundlerMetroVersion,
149
+ };
150
+ }
151
+ };
@@ -0,0 +1,19 @@
1
+ import { type CacheDomainId, type HarnessCachePaths } from './paths.js';
2
+ import { type CacheKeyInputs, type CacheSnapshot, type CacheSnapshotEntry, type DomainRestorePlan, type DomainSavePlan, type RestorePlan, type SavePlan, type SavePolicy } from './plan.js';
3
+ import { computeMetroStaticInputs, METRO_KEY_FILENAMES } from './domains/metro.js';
4
+ export { computeMetroStaticInputs, METRO_KEY_FILENAMES };
5
+ export type { CacheDomainId, HarnessCachePaths };
6
+ export type { CacheKeyInputs, CacheSnapshot, CacheSnapshotEntry, DomainRestorePlan, DomainSavePlan, RestorePlan, SavePlan, SavePolicy, };
7
+ export interface HarnessCache {
8
+ readonly paths: HarnessCachePaths;
9
+ isWarm(domain: CacheDomainId): boolean;
10
+ ensureDomainDirectories(domain: CacheDomainId): void;
11
+ planRestore(inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>): RestorePlan;
12
+ snapshot(): CacheSnapshot;
13
+ planSave(before: CacheSnapshot, policy: SavePolicy, inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>): SavePlan;
14
+ writeKeysFile(plan: RestorePlan | SavePlan): void;
15
+ }
16
+ export declare const createHarnessCache: (options: {
17
+ projectRoot: string;
18
+ }) => HarnessCache;
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAKL,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,UAAU,EAChB,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAEnF,OAAO,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,CAAC;AAEzD,YAAY,EAAE,aAAa,EAAE,iBAAiB,EAAE,CAAC;AACjD,YAAY,EACV,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,cAAc,EACd,WAAW,EACX,QAAQ,EACR,UAAU,GACX,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC;IACvC,uBAAuB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IACrD,WAAW,CACT,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,GACrD,WAAW,CAAC;IACf,QAAQ,IAAI,aAAa,CAAC;IAC1B,QAAQ,CACN,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,GACrD,QAAQ,CAAC;IACZ,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,QAAQ,GAAG,IAAI,CAAC;CACnD;AAID,eAAO,MAAM,kBAAkB,GAAI,SAAS;IAC1C,WAAW,EAAE,MAAM,CAAC;CACrB,KAAG,YAwBH,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import { logger } from '@react-native-harness/tools';
3
+ import { createHarnessCachePaths, getDomainDirectories, } from './paths.js';
4
+ import { planRestore, planSave, snapshot, writeKeysFile, } from './plan.js';
5
+ import { isDomainWarm } from './warmth.js';
6
+ import { computeMetroStaticInputs, METRO_KEY_FILENAMES } from './domains/metro.js';
7
+ export { computeMetroStaticInputs, METRO_KEY_FILENAMES };
8
+ const cacheLogger = logger.child('cache');
9
+ export const createHarnessCache = (options) => {
10
+ const paths = createHarnessCachePaths(options.projectRoot);
11
+ return {
12
+ paths,
13
+ isWarm: (domain) => isDomainWarm(domain, paths),
14
+ ensureDomainDirectories: (domain) => {
15
+ for (const directory of getDomainDirectories(domain, paths)) {
16
+ try {
17
+ fs.mkdirSync(directory, { recursive: true });
18
+ }
19
+ catch (error) {
20
+ cacheLogger.warn(`Failed to create cache directory "${directory}". Continuing with a cold cache.`, error);
21
+ }
22
+ }
23
+ },
24
+ planRestore: (inputs) => planRestore(paths, inputs),
25
+ snapshot: () => snapshot(paths),
26
+ planSave: (before, policy, inputs) => planSave(paths, before, policy, inputs),
27
+ writeKeysFile: (plan) => writeKeysFile(paths, plan),
28
+ };
29
+ };
@@ -0,0 +1,14 @@
1
+ export type CacheDomainId = 'metro';
2
+ export interface HarnessCachePaths {
3
+ readonly root: string;
4
+ readonly metroTransform: string;
5
+ readonly metroFileMap: string;
6
+ }
7
+ export declare const createHarnessCachePaths: (projectRoot: string) => HarnessCachePaths;
8
+ /**
9
+ * Directories that make up a cache domain. `metro` covers both the transform
10
+ * cache and the file-map (haste) cache so `ensureDomainDirectories` prepares
11
+ * both ahead of a run.
12
+ */
13
+ export declare const getDomainDirectories: (domain: CacheDomainId, paths: HarnessCachePaths) => readonly string[];
14
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC;AAEpC,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,eAAO,MAAM,uBAAuB,GAClC,aAAa,MAAM,KAClB,iBAQF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAC/B,QAAQ,aAAa,EACrB,OAAO,iBAAiB,KACvB,SAAS,MAAM,EAOjB,CAAC"}
package/dist/paths.js ADDED
@@ -0,0 +1,26 @@
1
+ import path from 'node:path';
2
+ const HARNESS_DIRNAME = '.harness';
3
+ const CACHE_DIRNAME = 'cache';
4
+ const METRO_TRANSFORM_DIRNAME = 'metro';
5
+ const METRO_FILE_MAP_DIRNAME = 'metro-file-map';
6
+ export const createHarnessCachePaths = (projectRoot) => {
7
+ const root = path.join(projectRoot, HARNESS_DIRNAME, CACHE_DIRNAME);
8
+ return {
9
+ root,
10
+ metroTransform: path.join(root, METRO_TRANSFORM_DIRNAME),
11
+ metroFileMap: path.join(root, METRO_FILE_MAP_DIRNAME),
12
+ };
13
+ };
14
+ /**
15
+ * Directories that make up a cache domain. `metro` covers both the transform
16
+ * cache and the file-map (haste) cache so `ensureDomainDirectories` prepares
17
+ * both ahead of a run.
18
+ */
19
+ export const getDomainDirectories = (domain, paths) => {
20
+ switch (domain) {
21
+ case 'metro':
22
+ return [paths.metroTransform, paths.metroFileMap];
23
+ default:
24
+ return [];
25
+ }
26
+ };
package/dist/plan.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { CacheDomainId, HarnessCachePaths } from './paths.js';
2
+ export interface CacheKeyInputs {
3
+ os: string;
4
+ staticInputs: Record<string, string>;
5
+ }
6
+ export interface DomainRestorePlan {
7
+ paths: string[];
8
+ restoreKey: string;
9
+ restorePrefixes: string[];
10
+ accretive: boolean;
11
+ }
12
+ export interface RestorePlan {
13
+ version: 1;
14
+ domains: Partial<Record<CacheDomainId, DomainRestorePlan>>;
15
+ }
16
+ export interface CacheSnapshotEntry {
17
+ path: string;
18
+ sizeBytes: number;
19
+ mtimeMs: number;
20
+ }
21
+ export type CacheSnapshot = Partial<Record<CacheDomainId, CacheSnapshotEntry[]>>;
22
+ export interface SavePolicy {
23
+ mode: 'default-branch' | 'always' | 'never';
24
+ isDefaultBranch: boolean;
25
+ }
26
+ export interface DomainSavePlan {
27
+ shouldSave: boolean;
28
+ saveKey: string;
29
+ delta: {
30
+ addedEntries: number;
31
+ removedEntries: number;
32
+ addedBytes: number;
33
+ };
34
+ }
35
+ export interface SavePlan {
36
+ version: 1;
37
+ domains: Partial<Record<CacheDomainId, DomainSavePlan>>;
38
+ }
39
+ export declare const planRestore: (paths: HarnessCachePaths, inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>) => RestorePlan;
40
+ /**
41
+ * Synchronous snapshot of on-disk cache contents, keyed by domain. Never
42
+ * throws: a domain whose directories cannot be read is reported as empty.
43
+ */
44
+ export declare const snapshot: (paths: HarnessCachePaths) => CacheSnapshot;
45
+ export declare const planSave: (paths: HarnessCachePaths, before: CacheSnapshot, policy: SavePolicy, inputs: Partial<Record<CacheDomainId, CacheKeyInputs>>) => SavePlan;
46
+ /**
47
+ * Written to `<cache-root>/keys.json`, outside any domain's cached
48
+ * subdirectories, so it is never itself part of what gets restored/saved.
49
+ * It is a secondary/debug artifact; the actual data flow between CI steps
50
+ * goes through GITHUB_OUTPUT, not by re-reading this file. Cache I/O must
51
+ * never fail a run, so write failures are logged and swallowed.
52
+ */
53
+ export declare const writeKeysFile: (paths: HarnessCachePaths, plan: RestorePlan | SavePlan) => void;
54
+ //# sourceMappingURL=plan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan.d.ts","sourceRoot":"","sources":["../src/plan.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAOnE,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,CAAC,CAAC;IACX,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,iBAAiB,CAAC,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,kBAAkB,EAAE,CAAC,CAAC,CAAC;AAEjF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,gBAAgB,GAAG,QAAQ,GAAG,OAAO,CAAC;IAC5C,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE;QACL,YAAY,EAAE,MAAM,CAAC;QACrB,cAAc,EAAE,MAAM,CAAC;QACvB,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;CACH;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,CAAC;IACX,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CAAC;CACzD;AA+ED,eAAO,MAAM,WAAW,GACtB,OAAO,iBAAiB,EACxB,QAAQ,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,KACrD,WAqBF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,iBAAiB,KAAG,aAoBnD,CAAC;AAgDF,eAAO,MAAM,QAAQ,GACnB,OAAO,iBAAiB,EACxB,QAAQ,aAAa,EACrB,QAAQ,UAAU,EAClB,QAAQ,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,KACrD,QA0BF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,GACxB,OAAO,iBAAiB,EACxB,MAAM,WAAW,GAAG,QAAQ,KAC3B,IASF,CAAC"}
package/dist/plan.js ADDED
@@ -0,0 +1,177 @@
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
+ import { getDomainDirectories } from './paths.js';
6
+ const cacheLogger = logger.child('cache');
7
+ const KEYS_FILENAME = 'keys.json';
8
+ const HASH_LENGTH = 16;
9
+ const ALL_DOMAINS = ['metro'];
10
+ /**
11
+ * Accretive domains only ever grow (transform/file-map caches keyed by
12
+ * content hash) so a save is always safe to key off the static hash plus a
13
+ * content hash, and prior saves can be found via a prefix match on the
14
+ * static hash. Non-accretive domains would need an exact key match instead.
15
+ * A domain not yet handled here defaults to non-accretive (exact-match),
16
+ * matching the safe-fallback style of `getDomainDirectories`/
17
+ * `getWarmthDirectory`: prefix-matching into an unknown domain's saves would
18
+ * not be a neutral no-op, so the conservative default is to require an exact
19
+ * key match rather than silently opt a future domain into accretive lookup.
20
+ */
21
+ const isAccretiveDomain = (domain) => {
22
+ switch (domain) {
23
+ case 'metro':
24
+ return true;
25
+ default:
26
+ return false;
27
+ }
28
+ };
29
+ const hashSortedEntries = (entries) => createHash('sha256')
30
+ .update(entries
31
+ .slice()
32
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
33
+ .map(([key, value]) => `${key}=${value}`)
34
+ .join('\n'))
35
+ .digest('hex')
36
+ .slice(0, HASH_LENGTH);
37
+ const buildStaticHashKey = (domain, inputs) => {
38
+ const hash = hashSortedEntries(Object.entries(inputs.staticInputs));
39
+ return `harness-${domain}-${inputs.os}-${hash}`;
40
+ };
41
+ const buildContentHash = (entries) => hashSortedEntries(entries.map((entry) => [entry.path, String(entry.sizeBytes)]));
42
+ const walkDirectory = (root, directory, entries) => {
43
+ let dirents;
44
+ try {
45
+ dirents = fs.readdirSync(directory, { withFileTypes: true });
46
+ }
47
+ catch {
48
+ // Missing/unreadable directory is treated as empty rather than an error.
49
+ return;
50
+ }
51
+ for (const dirent of dirents) {
52
+ const fullPath = path.join(directory, dirent.name);
53
+ if (dirent.isDirectory()) {
54
+ walkDirectory(root, fullPath, entries);
55
+ }
56
+ else if (dirent.isFile()) {
57
+ const stat = fs.statSync(fullPath);
58
+ entries.push({
59
+ path: path.relative(root, fullPath),
60
+ sizeBytes: stat.size,
61
+ mtimeMs: stat.mtimeMs,
62
+ });
63
+ }
64
+ }
65
+ };
66
+ export const planRestore = (paths, inputs) => {
67
+ const domains = {};
68
+ for (const domain of ALL_DOMAINS) {
69
+ const domainInputs = inputs[domain];
70
+ if (!domainInputs) {
71
+ continue;
72
+ }
73
+ const restoreKey = buildStaticHashKey(domain, domainInputs);
74
+ const accretive = isAccretiveDomain(domain);
75
+ domains[domain] = {
76
+ paths: [...getDomainDirectories(domain, paths)],
77
+ restoreKey,
78
+ restorePrefixes: accretive ? [`${restoreKey}-`] : [],
79
+ accretive,
80
+ };
81
+ }
82
+ return { version: 1, domains };
83
+ };
84
+ /**
85
+ * Synchronous snapshot of on-disk cache contents, keyed by domain. Never
86
+ * throws: a domain whose directories cannot be read is reported as empty.
87
+ */
88
+ export const snapshot = (paths) => {
89
+ const result = {};
90
+ for (const domain of ALL_DOMAINS) {
91
+ try {
92
+ const entries = [];
93
+ for (const directory of getDomainDirectories(domain, paths)) {
94
+ walkDirectory(paths.root, directory, entries);
95
+ }
96
+ result[domain] = entries;
97
+ }
98
+ catch (error) {
99
+ cacheLogger.warn(`Failed to snapshot cache domain "${domain}". Treating as empty.`, error);
100
+ result[domain] = [];
101
+ }
102
+ }
103
+ return result;
104
+ };
105
+ const diffSnapshots = (before, after) => {
106
+ const beforeByPath = new Map(before.map((entry) => [entry.path, entry]));
107
+ const afterByPath = new Map(after.map((entry) => [entry.path, entry]));
108
+ let addedEntries = 0;
109
+ let removedEntries = 0;
110
+ let addedBytes = 0;
111
+ for (const [entryPath, afterEntry] of afterByPath) {
112
+ const beforeEntry = beforeByPath.get(entryPath);
113
+ if (!beforeEntry || beforeEntry.sizeBytes !== afterEntry.sizeBytes) {
114
+ addedEntries += 1;
115
+ addedBytes += afterEntry.sizeBytes;
116
+ }
117
+ }
118
+ for (const entryPath of beforeByPath.keys()) {
119
+ if (!afterByPath.has(entryPath)) {
120
+ removedEntries += 1;
121
+ }
122
+ }
123
+ return { addedEntries, removedEntries, addedBytes };
124
+ };
125
+ const shouldSaveForPolicy = (policy, delta) => {
126
+ if (delta.addedEntries + delta.removedEntries === 0) {
127
+ return false;
128
+ }
129
+ switch (policy.mode) {
130
+ case 'always':
131
+ return true;
132
+ case 'default-branch':
133
+ return policy.isDefaultBranch;
134
+ case 'never':
135
+ return false;
136
+ }
137
+ };
138
+ export const planSave = (paths, before, policy, inputs) => {
139
+ const after = snapshot(paths);
140
+ const domains = {};
141
+ for (const domain of ALL_DOMAINS) {
142
+ const domainInputs = inputs[domain];
143
+ if (!domainInputs) {
144
+ continue;
145
+ }
146
+ const beforeEntries = before[domain] ?? [];
147
+ const afterEntries = after[domain] ?? [];
148
+ const delta = diffSnapshots(beforeEntries, afterEntries);
149
+ const staticHashKey = buildStaticHashKey(domain, domainInputs);
150
+ const accretive = isAccretiveDomain(domain);
151
+ domains[domain] = {
152
+ shouldSave: shouldSaveForPolicy(policy, delta),
153
+ saveKey: accretive
154
+ ? `${staticHashKey}-${buildContentHash(afterEntries)}`
155
+ : staticHashKey,
156
+ delta,
157
+ };
158
+ }
159
+ return { version: 1, domains };
160
+ };
161
+ /**
162
+ * Written to `<cache-root>/keys.json`, outside any domain's cached
163
+ * subdirectories, so it is never itself part of what gets restored/saved.
164
+ * It is a secondary/debug artifact; the actual data flow between CI steps
165
+ * goes through GITHUB_OUTPUT, not by re-reading this file. Cache I/O must
166
+ * never fail a run, so write failures are logged and swallowed.
167
+ */
168
+ export const writeKeysFile = (paths, plan) => {
169
+ const filePath = path.join(paths.root, KEYS_FILENAME);
170
+ try {
171
+ fs.mkdirSync(paths.root, { recursive: true });
172
+ fs.writeFileSync(filePath, JSON.stringify(plan, null, 2));
173
+ }
174
+ catch (error) {
175
+ cacheLogger.warn(`Failed to write cache keys file "${filePath}".`, error);
176
+ }
177
+ };