From 7502f4b2b4e11b218d654f4377e92d91e9cd9d3e Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sat, 29 Aug 2026 00:09:20 -0400 Subject: [PATCH] fix(metro): partition overridden cache roots --- packages/core/README.md | 2 + packages/core/index.ts | 33 ++++++- packages/metro/README.md | 15 ++- packages/metro/index.ts | 37 ++++++- packages/stim-cli/skill/SKILL.md | 2 + .../src/__tests__/cache-packages.test.ts | 56 +++++++++-- .../stim-cli/src/__tests__/caches.test.ts | 72 ++++++++++++++ packages/stim-cli/src/__tests__/gc.test.ts | 98 +++++++++++++++++++ packages/stim-cli/src/__tests__/guide.test.ts | 11 +++ packages/stim-cli/src/__tests__/paths.test.ts | 5 +- .../src/__tests__/supervisor-bare.test.ts | 45 ++++++++- packages/stim-cli/src/cache-manifest.ts | 4 + packages/stim-cli/src/caches.ts | 65 ++++++++++-- packages/stim-cli/src/commands/guide.ts | 8 ++ .../stim-cli/src/supervisor/metro-store.ts | 40 +++++--- .../stim-cli/src/supervisor/server-bare.ts | 2 +- 16 files changed, 456 insertions(+), 39 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index cd02750b..875f84f5 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,6 +6,8 @@ The primitives [`stim-cli`](https://www.npmjs.com/package/stim-cli), must agree on, implemented once: where the stim-cli config dir and the two shared caches live (env override > machine config > default), how a build cache key is derived, and how a cache registers itself for `stim-cli gc`. +Metro cache overrides name a parent directory; the sanitized app name is always +appended beneath it, just as it is beneath the default root. This is an internal dependency of those packages, not a user-facing API -- install one of them instead. It is ESM-only and has no dependencies. The diff --git a/packages/core/index.ts b/packages/core/index.ts index 08959ad2..c925667b 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -153,9 +153,8 @@ export function buildCacheRoot(): string { } export function metroCacheRoot(name?: string | null): string { - const override = process.env.STIM_CLI_METRO_CACHE || cachePathSetting('metroCache'); - if (override) return override; - const root = path.join(configDir(), 'metro-cache'); + const root = + process.env.STIM_CLI_METRO_CACHE || cachePathSetting('metroCache') || path.join(configDir(), 'metro-cache'); return name === undefined || name === null || name === '' ? root : path.join(root, cacheNameSegment(name)); } @@ -221,8 +220,20 @@ export interface RegisterOptions { prune: string; note: string; entriesDepth?: number; + layout?: string; + replaces?: CacheRegistrationMatch[]; } +export interface CacheRegistrationMatch { + dir: string; + name?: string; + prune?: string; + entriesDepth?: number; + layout?: string | null; +} + +export const METRO_NAMED_CACHE_LAYOUT = 'metro-named-v1'; + export interface CacheManifest { version: number; caches: Array>; @@ -264,14 +275,26 @@ export function updateCacheManifest( ); } -export function registerCache({ dir, name, prune, note, entriesDepth }: RegisterOptions): void { +export function registerCache({ dir, name, prune, note, entriesDepth, layout, replaces = [] }: RegisterOptions): void { try { updateCacheManifest(path.join(configDir(), 'caches.json'), (caches) => { - const others = caches.filter((cache) => cache.dir !== dir); + const others = caches.filter( + (cache) => cache.dir !== dir && !replaces.some((match) => matchesCache(cache, match)), + ); const record: Record = { dir, name, prune, note, registeredBy: process.cwd() }; if (entriesDepth) record.entriesDepth = entriesDepth; + if (layout) record.layout = layout; others.push(record); return others; }); } catch {} } + +function matchesCache(cache: Record, match: CacheRegistrationMatch): boolean { + if (cache.dir !== match.dir) return false; + if (match.name !== undefined && cache.name !== match.name) return false; + if (match.prune !== undefined && cache.prune !== match.prune) return false; + if (match.entriesDepth !== undefined && cache.entriesDepth !== match.entriesDepth) return false; + if (match.layout === null) return !Object.hasOwn(cache, 'layout'); + return match.layout === undefined || cache.layout === match.layout; +} diff --git a/packages/metro/README.md b/packages/metro/README.md index 969359d9..1f107073 100644 --- a/packages/metro/README.md +++ b/packages/metro/README.md @@ -39,7 +39,20 @@ only the entries nothing has touched, not the whole cache. stim-cli is an optional peer. Without it the cache works exactly the same; it is just invisible to housekeeping. -The location can be overridden by `STIM_CLI_METRO_CACHE`, or machine-wide by `caches.metroCache` in `~/.stim-cli/config.json` (an absolute path; the env var wins). The CLI and this package resolve both identically, so they always share one store. +The parent location can be overridden by `STIM_CLI_METRO_CACHE`, or +machine-wide by `caches.metroCache` in `~/.stim-cli/config.json` (an absolute +path; the env var wins). The sanitized name passed to `sharedCacheStores` is +always appended below that parent, so `sharedCacheStores('@scope/app')` uses +`/-scope-app`. The CLI and this package resolve both identically. + +Earlier versions wrote a named store directly into an overridden parent. A new +registration marks the named layout and replaces an exact unmarked legacy +parent entry. If an older package registers the parent again later, current +`stim-cli gc` ignores that provably legacy entry while a marked child is +registered, so it cannot prune the child at the wrong depth. A marked named +store that later becomes another override parent remains visible but report-only +while its marked child exists. The old root-level cache files are left untouched +for manual cleanup. ## The NDJSON log reporter diff --git a/packages/metro/index.ts b/packages/metro/index.ts index ccf8e49f..fe4adfa7 100644 --- a/packages/metro/index.ts +++ b/packages/metro/index.ts @@ -1,7 +1,13 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; -import { metroCacheRoot, registerCache, tagSharedStore, workspaceLogDir } from '@stim-cli/core'; +import { + metroCacheRoot, + METRO_NAMED_CACHE_LAYOUT, + registerCache, + tagSharedStore, + workspaceLogDir, +} from '@stim-cli/core'; type FileStoreCtor = new (options: { root: string }) => object; @@ -38,21 +44,46 @@ export function cacheRoot(name?: string | null): string { return metroCacheRoot(name); } -function registerOnce(dir: string): void { +function registerOnce( + dir: string, + replaces: Array<{ + dir: string; + name: string; + prune: string; + entriesDepth: number; + layout: null; + }> = [], +): void { registerCache({ dir, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, note: 'shared Metro transforms; no eviction of its own', + replaces, }); } export function sharedCacheStores(name = 'app', { FileStore }: { FileStore?: FileStoreCtor } = {}): object[] { // metro-cache is a peer dependency that resolves at call time. const Store: FileStoreCtor = FileStore || (requireFromHere('metro-cache') as { FileStore: FileStoreCtor }).FileStore; + const parent = cacheRoot(); const root = cacheRoot(name); - registerOnce(root); + registerOnce( + root, + root === parent + ? [] + : [ + { + dir: parent, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: null, + }, + ], + ); return [tagSharedStore(new Store({ root }), root)]; } diff --git a/packages/stim-cli/skill/SKILL.md b/packages/stim-cli/skill/SKILL.md index 2ccc88b5..265643c8 100644 --- a/packages/stim-cli/skill/SKILL.md +++ b/packages/stim-cli/skill/SKILL.md @@ -121,6 +121,8 @@ Also normal: `npx` may re-download stim-cli on every invocation (`npm warn exec These caches can grow substantially. Every `npx stim-cli gc` run reports what they have grown to -- each one tagged _registered_ or _detected_ -- and `gc --delete --older-than ` trims the entries nothing has used. Trim rather than empty: `gc --delete --all` empties them whole -- the only way to clear an index-backed cache like Xcode's CAS -- and costs the next build in every project the time the cache was saving. The Gradle build cache under `GRADLE_USER_HOME` (default `~/.gradle`) has Gradle's own retention policy and is report-only to stim-cli: stim-cli reports its size because `--build-cache` contributes to it, but never prunes or empties that directory under any `gc` flags because every Gradle build on the machine shares it. +`STIM_CLI_METRO_CACHE` and the machine-level `caches.metroCache` setting name a parent directory, not one flat Metro store. stim-cli appends the sanitized package name beneath that parent so each app stays independently reportable and prunable. A new registration marks the named layout and replaces an exact unmarked legacy parent entry. If an older cache package registers the parent again later, current `stim-cli gc` ignores that provably legacy entry while a marked child exists. A marked store that later becomes another override parent remains visible but report-only while its marked child exists. Root-level legacy files remain untouched for manual cleanup. + ## Runtime workspace and fingerprint dependencies Runtime state is stored outside the project tree under diff --git a/packages/stim-cli/src/__tests__/cache-packages.test.ts b/packages/stim-cli/src/__tests__/cache-packages.test.ts index 83466556..7c0f400f 100644 --- a/packages/stim-cli/src/__tests__/cache-packages.test.ts +++ b/packages/stim-cli/src/__tests__/cache-packages.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import assert from 'node:assert'; +import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core'; import { readManifest } from '../cache-manifest.ts'; import { sharedBuildCache, sharedMetroCache } from '../paths.ts'; import { hasStoreAt } from '../supervisor/metro-store.ts'; @@ -46,9 +47,28 @@ test('the Expo build cache provider registers itself on this Node, at the right test('the Metro cache store registers itself on this Node, at the shard depth', async () => { const home = mkdtempSync(join(tmpdir(), 'stim-cli-pkg-home2-')); const cacheRoot = join(tmpdir(), `stim-cli-pkg-metro-${process.pid}`); + const namedRoot = join(cacheRoot, 'demo'); mkdirSync(cacheRoot, { recursive: true }); process.env.STIM_CLI_HOME = home; process.env.STIM_CLI_METRO_CACHE = cacheRoot; + writeFileSync( + join(home, 'caches.json'), + JSON.stringify({ + version: 1, + caches: [ + { dir: cacheRoot, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 }, + { + dir: cacheRoot, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }, + { dir: cacheRoot, name: 'Unrelated same-root cache', prune: 'entries' }, + { dir: join(home, 'unrelated'), name: 'Unrelated cache', prune: 'entries' }, + ], + }), + ); try { const { sharedCacheStores } = await import('@stim-cli/metro'); class FakeStore { @@ -58,14 +78,25 @@ test('the Metro cache store registers itself on this Node, at the shard depth', } } const stores = sharedCacheStores('demo', { FileStore: FakeStore }); - expect((stores[0] as { root: string }).root).toBe(cacheRoot); - expect(hasStoreAt(stores, cacheRoot)).toBe(true); + expect((stores[0] as { root: string }).root).toBe(namedRoot); + expect(hasStoreAt(stores, namedRoot)).toBe(true); - const record = await waitForRegistration(cacheRoot); + const record = await waitForRegistration(namedRoot); expect(record).toBeTruthy(); assert(record); expect(record.entriesDepth).toBe(2); expect(record.prune).toBe('entries'); + expect(record.layout).toBe(METRO_NAMED_CACHE_LAYOUT); + expect( + readManifest().caches.some( + (cache) => cache.dir === cacheRoot && cache.name === 'Metro transform cache' && cache.layout === undefined, + ), + ).toBe(false); + expect( + readManifest().caches.some((cache) => cache.dir === cacheRoot && cache.layout === METRO_NAMED_CACHE_LAYOUT), + ).toBe(true); + expect(readManifest().caches.some((cache) => cache.name === 'Unrelated same-root cache')).toBe(true); + expect(readManifest().caches.some((cache) => cache.dir === join(home, 'unrelated'))).toBe(true); } finally { rmSync(home, { recursive: true, force: true }); rmSync(cacheRoot, { recursive: true, force: true }); @@ -105,17 +136,28 @@ test('both packages resolve the same cache roots the CLI does', async () => { ); expect(provider.cacheRoot()).toBe(sharedBuildCache()); expect(provider.cacheRoot()).toBe(join(home, 'cfg-build')); + expect(metro.cacheRoot()).toBe(join(home, 'cfg-metro')); expect(metro.cacheRoot('demo')).toBe(sharedMetroCache('demo')); - expect(metro.cacheRoot('demo')).toBe(join(home, 'cfg-metro')); - writeFileSync(join(home, 'config.json'), JSON.stringify({ caches: { buildCache: 'relative/nope' } })); - expect(provider.cacheRoot()).toBe(join(home, 'build-cache')); + expect(metro.cacheRoot('demo')).toBe(join(home, 'cfg-metro', 'demo')); + expect(metro.cacheRoot('@scope/app')).toBe(join(home, 'cfg-metro', '-scope-app')); process.env.STIM_CLI_BUILD_CACHE = join(home, 'elsewhere-build'); process.env.STIM_CLI_METRO_CACHE = join(home, 'elsewhere-metro'); expect(provider.cacheRoot()).toBe(sharedBuildCache()); expect(provider.cacheRoot()).toBe(join(home, 'elsewhere-build')); + expect(metro.cacheRoot()).toBe(join(home, 'elsewhere-metro')); expect(metro.cacheRoot('demo')).toBe(sharedMetroCache('demo')); - expect(metro.cacheRoot('demo')).toBe(join(home, 'elsewhere-metro')); + expect(metro.cacheRoot('demo')).toBe(join(home, 'elsewhere-metro', 'demo')); + + delete process.env.STIM_CLI_BUILD_CACHE; + delete process.env.STIM_CLI_METRO_CACHE; + writeFileSync( + join(home, 'config.json'), + JSON.stringify({ caches: { buildCache: 'relative/nope', metroCache: 'relative/nope' } }), + ); + expect(provider.cacheRoot()).toBe(join(home, 'build-cache')); + expect(metro.cacheRoot()).toBe(join(home, 'metro-cache')); + expect(metro.cacheRoot('demo')).toBe(join(home, 'metro-cache', 'demo')); } finally { rmSync(home, { recursive: true, force: true }); delete process.env.STIM_CLI_HOME; diff --git a/packages/stim-cli/src/__tests__/caches.test.ts b/packages/stim-cli/src/__tests__/caches.test.ts index 1fa61f1b..5c9a7495 100644 --- a/packages/stim-cli/src/__tests__/caches.test.ts +++ b/packages/stim-cli/src/__tests__/caches.test.ts @@ -16,6 +16,7 @@ import { register } from '../cache-manifest.ts'; import { makeCacheDescriptor } from './_factories.ts'; import { setProjectSetting, upsertProject } from '../config.ts'; import assert from 'node:assert'; +import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core'; const LONG_AGO = new Date(Date.now() - 90 * 24 * 3600 * 1000); @@ -350,3 +351,74 @@ test('a declared path that only differs in spelling dedups against the registrat rmSync(dir, { recursive: true, force: true }); } }); + +test('a named Metro store suppresses only its known legacy parent registration', () => { + const ancestor = join(tmpHome, 'cache-owner'); + const parent = join(ancestor, 'metro'); + const child = join(parent, 'demo'); + mkdirSync(child, { recursive: true }); + writeFileSync( + join(tmpHome, 'caches.json'), + JSON.stringify({ + version: 1, + caches: [ + { + dir: child, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }, + { dir: parent, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 }, + { dir: parent, name: 'Unrelated same-root cache', prune: 'entries', entriesDepth: 1 }, + { dir: ancestor, name: 'Unrelated ancestor cache', prune: 'entries', entriesDepth: 1 }, + ], + }), + ); + + const caches = discoverCaches(); + + expect(caches.some((cache) => cache.dir === parent && cache.name === 'Metro transform cache')).toBe(false); + expect(caches.some((cache) => cache.dir === child && cache.name === 'Metro transform cache')).toBe(true); + expect(caches.some((cache) => cache.dir === parent && cache.name === 'Unrelated same-root cache')).toBe(true); + expect(caches.some((cache) => cache.dir === ancestor && cache.name === 'Unrelated ancestor cache')).toBe(true); +}); + +test('current nested Metro stores preserve the parent as report-only and unmarked children prove no migration', () => { + const currentParent = join(tmpHome, 'current'); + const currentChild = join(currentParent, 'child'); + const unmarkedParent = join(tmpHome, 'unmarked'); + const unmarkedChild = join(unmarkedParent, 'child'); + for (const dir of [currentChild, unmarkedChild]) mkdirSync(dir, { recursive: true }); + writeFileSync( + join(tmpHome, 'caches.json'), + JSON.stringify({ + version: 1, + caches: [ + { + dir: currentParent, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }, + { + dir: currentChild, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }, + { dir: unmarkedParent, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 }, + { dir: unmarkedChild, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 }, + ], + }), + ); + + const caches = discoverCaches(); + + expect(caches.find((cache) => cache.dir === currentParent)?.prune).toBe('report-only'); + expect(caches.find((cache) => cache.dir === currentChild)?.prune).toBe('entries'); + expect(caches.some((cache) => cache.dir === unmarkedParent)).toBe(true); + expect(caches.some((cache) => cache.dir === unmarkedChild)).toBe(true); +}); diff --git a/packages/stim-cli/src/__tests__/gc.test.ts b/packages/stim-cli/src/__tests__/gc.test.ts index f8cfc157..45c975ae 100644 --- a/packages/stim-cli/src/__tests__/gc.test.ts +++ b/packages/stim-cli/src/__tests__/gc.test.ts @@ -13,6 +13,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core'; import { Command } from 'commander'; import { setExecutor, resetExecutor } from '../exec.ts'; import { saveConfig, loadConfig } from '../config.ts'; @@ -2008,6 +2009,103 @@ test('--delete --older-than trims the cache entries nothing has touched', async expect(existsSync(freshEntry)).toBeTruthy(); }); +test('--delete --older-than ignores a legacy Metro parent registered after its named child', async () => { + const parent = join(tmpHome, 'metro-cache'); + const child = join(parent, 'demo'); + const shard = join(child, '0a'); + const currentTransform = join(shard, 'current'); + const legacyShard = join(parent, '1b'); + const legacyTransform = join(legacyShard, 'legacy'); + mkdirSync(shard, { recursive: true }); + mkdirSync(legacyShard, { recursive: true }); + writeFileSync(currentTransform, 'current'); + writeFileSync(legacyTransform, 'legacy'); + const old = new Date(Date.now() - 400 * DAY_MS); + utimesSync(shard, old, old); + utimesSync(legacyTransform, old, old); + register({ + dir: child, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }); + register({ dir: parent, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 }); + saveConfig({ version: 2, projects: {}, repos: {} }); + installExecutor(); + + await cli(['--delete', '--older-than', '30']); + + expect(existsSync(currentTransform)).toBe(true); + expect(existsSync(legacyTransform)).toBe(true); +}); + +test('--delete --older-than ignores a legacy Metro parent whose named child is a symlink', async () => { + const parent = join(tmpHome, 'metro-cache'); + const target = join(tmpHome, 'metro-target'); + const child = join(parent, 'demo'); + const shard = join(target, '0a'); + const currentTransform = join(shard, 'current'); + mkdirSync(parent, { recursive: true }); + mkdirSync(shard, { recursive: true }); + writeFileSync(currentTransform, 'current'); + symlinkSync(target, child, 'dir'); + const old = new Date(Date.now() - 400 * DAY_MS); + utimesSync(shard, old, old); + register({ + dir: child, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }); + register({ dir: parent, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 }); + saveConfig({ version: 2, projects: {}, repos: {} }); + installExecutor(); + + await cli(['--delete', '--older-than', '30']); + + expect(existsSync(currentTransform)).toBe(true); +}); + +test('--delete --older-than keeps a current Metro store that is also a current override parent', async () => { + const parent = join(tmpHome, 'metro-cache', 'first-app'); + const child = join(parent, 'second-app'); + const parentTransform = join(parent, '0a', 'old-parent-transform'); + const childTransform = join(child, '1b', 'current-child-transform'); + mkdirSync(dirname(parentTransform), { recursive: true }); + mkdirSync(dirname(childTransform), { recursive: true }); + writeFileSync(parentTransform, 'parent'); + writeFileSync(childTransform, 'child'); + const old = new Date(Date.now() - 400 * DAY_MS); + utimesSync(parentTransform, old, old); + utimesSync(dirname(childTransform), old, old); + register({ + dir: parent, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }); + register({ + dir: child, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }); + saveConfig({ version: 2, projects: {}, repos: {} }); + installExecutor(); + + const report = await collectGcReport({ olderThan: 30 }); + expect(report.caches.find((cache) => cache.dir === parent)?.prune).toBe('report-only'); + expect(report.caches.find((cache) => cache.dir === child)?.prune).toBe('entries'); + await cli(['--delete', '--older-than', '30']); + + expect(existsSync(parentTransform)).toBe(true); + expect(existsSync(childTransform)).toBe(true); +}); + test('--delete --all empties an index-backed cache that --older-than cannot trim', async () => { const casDir = join(tmpHome, 'compilation-cache'); const leaf = join(casDir, 'v9.data.leaf'); diff --git a/packages/stim-cli/src/__tests__/guide.test.ts b/packages/stim-cli/src/__tests__/guide.test.ts index d9f3816f..7e22be01 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -203,6 +203,17 @@ test('the cleanup guide documents that the shared Gradle build cache is report-o expect(cleanup).toMatch(/never[^.]*prunes[^.]*empties/i); }); +test('the settings guide defines Metro overrides as parent roots and preserves legacy files', () => { + const settings = renderTopic('settings'); + assert(settings); + + expect(settings).toMatch(/Metro value is a PARENT root/i); + expect(settings).toMatch(/sanitized package name[^.]*appended/i); + expect(settings).toMatch(/older package[^.]*current gc ignores[^.]*unmarked legacy parent/i); + expect(settings).toMatch(/marked store[^.]*override parent[^.]*report-only/i); + expect(settings).toMatch(/root-level legacy files remain[^.]*untouched/i); +}); + test('the guide distinguishes local stop behavior from EAS session teardown', () => { const lifecycle = renderTopic('lifecycle'); assert(lifecycle); diff --git a/packages/stim-cli/src/__tests__/paths.test.ts b/packages/stim-cli/src/__tests__/paths.test.ts index 786e4783..65246068 100644 --- a/packages/stim-cli/src/__tests__/paths.test.ts +++ b/packages/stim-cli/src/__tests__/paths.test.ts @@ -123,13 +123,14 @@ describe('shared cache roots', () => { delete process.env.STIM_CLI_METRO_CACHE; }); - test('explicit cache env overrides win over the layout', () => { + test('explicit cache env overrides win and remain parent roots', () => { process.env.STIM_CLI_BUILD_CACHE = '/tmp/custom-build'; expect(sharedBuildCache()).toBe('/tmp/custom-build'); process.env.STIM_CLI_METRO_CACHE = '/tmp/custom-metro'; expect(sharedMetroCache()).toBe('/tmp/custom-metro'); - expect(sharedMetroCache('demo')).toBe('/tmp/custom-metro'); + expect(sharedMetroCache('demo')).toBe('/tmp/custom-metro/demo'); + expect(sharedMetroCache('@scope/app')).toBe('/tmp/custom-metro/-scope-app'); }); test('a named Metro cache is a subdirectory, and cannot escape the root', () => { diff --git a/packages/stim-cli/src/__tests__/supervisor-bare.test.ts b/packages/stim-cli/src/__tests__/supervisor-bare.test.ts index 93983732..95eb8049 100644 --- a/packages/stim-cli/src/__tests__/supervisor-bare.test.ts +++ b/packages/stim-cli/src/__tests__/supervisor-bare.test.ts @@ -1,8 +1,16 @@ import assert from 'node:assert'; +import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { appendCacheStore, hasStoreAt, metroStoreName, metroStoreRoot } from '../supervisor/metro-store.ts'; +import { + appendCacheStore, + hasStoreAt, + metroStoreName, + metroStoreRoot, + registerMetroStore, +} from '../supervisor/metro-store.ts'; +import { readManifest } from '../cache-manifest.ts'; import { BARE_PACKAGES, checkBareApi, @@ -38,6 +46,7 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); rmSync(tmpHome, { recursive: true, force: true }); delete process.env.STIM_CLI_HOME; + delete process.env.STIM_CLI_METRO_CACHE; }); function fakeRequire( @@ -472,6 +481,40 @@ describe('the shared Metro cache store', () => { rmSync(nameless, { recursive: true, force: true }); } }); + + test('the CLI replaces a legacy flat override registration with the named store', () => { + const parent = join(tmpHome, 'overridden-metro'); + process.env.STIM_CLI_METRO_CACHE = parent; + writeFileSync( + join(tmpHome, 'caches.json'), + JSON.stringify({ + version: 1, + caches: [ + { dir: parent, name: 'Metro transform cache', prune: 'entries', entriesDepth: 2 }, + { + dir: parent, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + }, + ], + }), + ); + + const storeRoot = metroStoreRoot(root); + expect(storeRoot).toBe(join(parent, 'bare')); + registerMetroStore(storeRoot); + + const caches = readManifest().caches; + expect(caches.some((cache) => cache.dir === parent && cache.layout === undefined)).toBe(false); + expect(caches.some((cache) => cache.dir === parent && cache.layout === METRO_NAMED_CACHE_LAYOUT)).toBe(true); + expect(caches.find((cache) => cache.dir === storeRoot)).toMatchObject({ + entriesDepth: 2, + prune: 'entries', + layout: METRO_NAMED_CACHE_LAYOUT, + }); + }); }); describe('startBareServer and the shared store', () => { diff --git a/packages/stim-cli/src/cache-manifest.ts b/packages/stim-cli/src/cache-manifest.ts index a9ad033c..b747b03f 100644 --- a/packages/stim-cli/src/cache-manifest.ts +++ b/packages/stim-cli/src/cache-manifest.ts @@ -11,6 +11,7 @@ export interface CacheEntry { entriesDepth?: number; note?: string; registeredBy?: string; + layout?: string; } export function manifestPath(): string { @@ -40,6 +41,7 @@ export function register(entry: CacheEntry, path: string = manifestPath()): Cach note: entry.note || 'registered by the project', registeredBy: entry.registeredBy || process.cwd(), }; + if (entry.layout) record.layout = entry.layout; updateCacheManifest(path, (caches) => { const others = cacheEntries(caches).filter((cache) => expand(cache.dir) !== dir); return [...others, record]; @@ -71,6 +73,7 @@ export function registeredCaches(path: string = manifestPath()): { prune: 'atomic' | 'entries'; entriesDepth: number; note: string | undefined; + layout: string | undefined; }[] { return readManifest(path) .caches.filter((c) => c.dir && existsSync(c.dir)) @@ -80,5 +83,6 @@ export function registeredCaches(path: string = manifestPath()): { prune: c.prune === 'atomic' ? 'atomic' : 'entries', entriesDepth: normalizeDepth(c.entriesDepth), note: c.note, + layout: c.layout, })); } diff --git a/packages/stim-cli/src/caches.ts b/packages/stim-cli/src/caches.ts index 5ee127fe..7b6feff3 100644 --- a/packages/stim-cli/src/caches.ts +++ b/packages/stim-cli/src/caches.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, realpathSync, rmSync, statSync } from 'fs'; import { homedir, tmpdir } from 'os'; -import { isAbsolute, join, relative, resolve } from 'path'; +import { dirname, isAbsolute, join, relative, resolve } from 'path'; +import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core'; import { directorySize } from './fs-util.ts'; import { registeredCaches } from './cache-manifest.ts'; import { findProjectRoot } from './project.ts'; @@ -13,6 +14,7 @@ export interface CacheDescriptor { prune: 'atomic' | 'entries' | 'report-only'; note: string; entriesDepth?: number; + layout?: string; files?: string[]; bytes?: number; source?: 'registered' | 'detected'; @@ -92,13 +94,17 @@ export function declaredCachePaths(cwd: string = process.cwd()): string[] { export function discoverCaches({ declared = [] }: { declared?: string[] } = {}): CacheDescriptor[] { const gradle = gradleBuildCache(); const gradleDir = gradle ? canonicalCacheDir(gradle.dir) : null; - const registered = registeredCaches().map((c): CacheDescriptor => - Object.assign({}, c, { - name: c.name ?? c.dir, - note: c.note ?? 'registered', - prune: c.prune, - source: 'registered' as const, - }), + const registered = protectNestedMetroAncestors( + suppressLegacyMetroAncestors( + registeredCaches().map((c): CacheDescriptor => + Object.assign({}, c, { + name: c.name ?? c.dir, + note: c.note ?? 'registered', + prune: c.prune, + source: 'registered' as const, + }), + ), + ), ); const detected = [compilationCache(), gradle, metroFileMaps(), ...declaredCaches(declared)] .filter((c): c is CacheDescriptor => Boolean(c)) @@ -106,6 +112,49 @@ export function discoverCaches({ declared = [] }: { declared?: string[] } = {}): return mergeCacheDescriptors([...registered, ...detected], gradleDir); } +function suppressLegacyMetroAncestors(caches: CacheDescriptor[]): CacheDescriptor[] { + const current = caches.filter(isCurrentMetroStore); + return caches.filter((cache) => { + if (!isLegacyMetroStore(cache)) return true; + return !current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir)); + }); +} + +function protectNestedMetroAncestors(caches: CacheDescriptor[]): CacheDescriptor[] { + const current = caches.filter(isCurrentMetroStore); + return caches.map((cache) => { + if (!isCurrentMetroStore(cache)) return cache; + if (!current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir))) return cache; + return Object.assign({}, cache, { + prune: 'report-only' as const, + note: 'named Metro store is also an override parent; report only while its child is registered', + }); + }); +} + +function isLegacyMetroStore(cache: CacheDescriptor): boolean { + return isMetroStore(cache) && cache.layout === undefined; +} + +function isCurrentMetroStore(cache: CacheDescriptor): boolean { + return isMetroStore(cache) && cache.layout === METRO_NAMED_CACHE_LAYOUT; +} + +function isMetroStore(cache: CacheDescriptor): boolean { + return ( + cache.source === 'registered' && + cache.name === 'Metro transform cache' && + cache.prune === 'entries' && + cache.entriesDepth === 2 + ); +} + +function isNamedMetroChild(parent: string, candidate: string): boolean { + const dir = canonicalCacheDir(parent); + const child = canonicalCacheDir(candidate); + return dirname(child) === dir || canonicalCacheDir(dirname(candidate)) === dir; +} + function mergeCacheDescriptors(caches: CacheDescriptor[], gradleDir: string | null): CacheDescriptor[] { const merged: CacheDescriptor[] = []; for (const cache of caches) { diff --git a/packages/stim-cli/src/commands/guide.ts b/packages/stim-cli/src/commands/guide.ts index 2258c772..9f13a13a 100644 --- a/packages/stim-cli/src/commands/guide.ts +++ b/packages/stim-cli/src/commands/guide.ts @@ -1370,6 +1370,14 @@ The shared build cache and Metro transform cache default to living under STIM_CLI_BUILD_CACHE / STIM_CLI_METRO_CACHE in the environment override the file. The CLI and both cache packages resolve these identically, so every process finds the same store regardless of shell profile. A relative path is ignored. +The Metro value is a PARENT root. The sanitized package name is appended below +it, so apps remain separately reportable and prunable. Earlier releases used an +overridden Metro root as one flat store. A new registration replaces that legacy +parent entry and marks the named layout. If an older package registers it again, +current gc ignores the exact unmarked legacy parent while a marked child exists. +A marked store that later becomes another override parent remains visible but is +report-only while its marked child exists. Root-level legacy files remain +untouched for manual cleanup. PREFER SELF-REGISTRATION OVER THE 'caches' SETTING There is no 'cache' command. A cache registers itself from code instead, once, diff --git a/packages/stim-cli/src/supervisor/metro-store.ts b/packages/stim-cli/src/supervisor/metro-store.ts index c2295e6c..5747f285 100644 --- a/packages/stim-cli/src/supervisor/metro-store.ts +++ b/packages/stim-cli/src/supervisor/metro-store.ts @@ -1,8 +1,13 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { metroCacheRoot, sharedStoreRoot, tagSharedStore } from '@stim-cli/core'; -import { register } from '../cache-manifest.ts'; +import { + metroCacheRoot, + METRO_NAMED_CACHE_LAYOUT, + registerCache, + sharedStoreRoot, + tagSharedStore, +} from '@stim-cli/core'; type FileStoreCtor = new (options: { root: string }) => object; @@ -20,15 +25,28 @@ export function metroStoreRoot(root: string): string { } export function registerMetroStore(storeRoot: string): void { - try { - register({ - dir: storeRoot, - name: 'Metro transform cache', - prune: 'entries', - entriesDepth: 2, - note: 'shared Metro transforms, installed by stim-cli start; no eviction of its own', - }); - } catch {} + const parent = resolve(metroCacheRoot()); + const dir = resolve(storeRoot); + registerCache({ + dir, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: METRO_NAMED_CACHE_LAYOUT, + note: 'shared Metro transforms, installed by stim-cli start; no eviction of its own', + replaces: + dir === parent + ? [] + : [ + { + dir: parent, + name: 'Metro transform cache', + prune: 'entries', + entriesDepth: 2, + layout: null, + }, + ], + }); } export function hasStoreAt(stores: unknown, storeRoot: string): boolean { diff --git a/packages/stim-cli/src/supervisor/server-bare.ts b/packages/stim-cli/src/supervisor/server-bare.ts index 2135f566..364765c8 100644 --- a/packages/stim-cli/src/supervisor/server-bare.ts +++ b/packages/stim-cli/src/supervisor/server-bare.ts @@ -160,6 +160,7 @@ function installSharedCacheStore({ } const storeRoot = metroStoreRoot(root); const result = appendCacheStore(config, { storeRoot, FileStore }); + registerMetroStore(storeRoot); if (!result.added) { writer?.write({ src: 'metro', @@ -169,7 +170,6 @@ function installSharedCacheStore({ }); return true; } - registerMetroStore(storeRoot); writer?.write({ src: 'metro', level: 'debug',