diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts index 0bbdbbe1..f7ba278d 100644 --- a/packages/rstack/src/fmt/cacheIdentity.ts +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -1,4 +1,4 @@ -import { hash } from 'node:crypto'; +import { hash as createDigest } from 'node:crypto'; import { isAbsolute } from 'node:path'; import stableStringify from 'fast-json-stable-stringify'; import { fmtCacheVersion } from './cacheStore.ts'; @@ -12,7 +12,9 @@ type CacheKeyResolver = (filePath: string) => string | undefined; type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; type PluginFingerprints = ReadonlyMap; -const sha256 = (content: string | Uint8Array): string => hash('sha256', content, 'hex'); +const cacheHashLength = 16; +const createCacheHash = (content: string | Uint8Array): string => + createDigest('sha256', content, 'base64url').slice(0, cacheHashLength); /** Identifies formatter behavior shared by all cache entries in this process. */ const cacheNamespace: string = JSON.stringify([fmtCacheVersion, RSTACK_VERSION, PRETTIER_VERSION]); @@ -55,7 +57,7 @@ const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHa } value = { ...options, plugins: fingerprints }; } - hash = sha256(stableStringify(value)); + hash = createCacheHash(stableStringify(value)); } catch { // Circular or unreadable options cannot be cached. } @@ -65,4 +67,10 @@ const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHa }; }; -export { cacheNamespace, createCacheKeyResolver, createOptionsHasher, sha256 }; +export { + cacheHashLength, + cacheNamespace, + createCacheHash, + createCacheKeyResolver, + createOptionsHasher, +}; diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index dfc47b60..0dcf3292 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -2,18 +2,40 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; -const fmtCacheFileName = 'v1.json'; -const fmtCacheVersion = 1; +const fmtCacheFileName = 'cache.json'; +const fmtCacheVersion = 2; -type FmtCacheState = 'clean' | 'dirty' | 'unsupported'; -type FmtCacheEntry = - | readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty'] - | readonly [contentHash: string | null, optionsHash: string, state: 'unsupported']; +const fileEntryWidth = 4; +const contentHashOffset = 1; +const optionsIndexOffset = 2; +const stateOffset = 3; + +const fmtCacheStates = ['clean', 'dirty', 'unsupported'] as const; +type FmtCacheState = (typeof fmtCacheStates)[number]; +type FmtCacheStateId = 0 | 1 | 2; + +const fmtCacheStateIds = { + clean: 0, + dirty: 1, + unsupported: 2, +} as const satisfies Record; + +type FmtCacheFileValue = string | number; +type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState]; interface FmtCacheFile { version: typeof fmtCacheVersion; namespace: string; - files: Record; + options: string[]; + /** Repeated tuples of file path, content hash, options index, and numeric state. */ + files: FmtCacheFileValue[]; +} + +interface ParsedFmtCacheFile { + cache: FmtCacheFile; + fileOffsets: Map; + optionsIndexes: Map; + optionsUseCounts: number[]; } interface FmtCacheStore { @@ -23,30 +45,19 @@ interface FmtCacheStore { save(): Promise; } -const createEmptyCache = (namespace: string): FmtCacheFile => ({ - version: fmtCacheVersion, - namespace, - files: Object.create(null) as Record, +const createEmptyCache = (namespace: string): ParsedFmtCacheFile => ({ + cache: { + version: fmtCacheVersion, + namespace, + options: [], + files: [], + }, + fileOffsets: new Map(), + optionsIndexes: new Map(), + optionsUseCounts: [], }); -const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => { - if (!Array.isArray(value) || value.length !== 3 || typeof value[1] !== 'string') { - return; - } - - if (value[2] === 'unsupported') { - return value[0] === null || typeof value[0] === 'string' - ? [value[0], value[1], value[2]] - : undefined; - } - if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) { - return; - } - - return [value[0], value[1], value[2]]; -}; - -const parseCacheFile = (content: string): FmtCacheFile | undefined => { +const parseCacheFile = (content: string): ParsedFmtCacheFile | undefined => { let value: unknown; try { value = JSON.parse(content); @@ -54,35 +65,41 @@ const parseCacheFile = (content: string): FmtCacheFile | undefined => { return; } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return; + } + + const cache = value as FmtCacheFile; + const { version, namespace, options, files } = cache; if ( - typeof value !== 'object' || - value === null || - Array.isArray(value) || - !('version' in value) || - value.version !== fmtCacheVersion || - !('namespace' in value) || - typeof value.namespace !== 'string' || - !('files' in value) || - typeof value.files !== 'object' || - value.files === null || - Array.isArray(value.files) + version !== fmtCacheVersion || + typeof namespace !== 'string' || + !Array.isArray(options) || + !Array.isArray(files) || + files.length % fileEntryWidth !== 0 ) { return; } - const files = Object.create(null) as Record; - for (const [filePath, rawEntry] of Object.entries(value.files)) { - const entry = parseCacheEntry(rawEntry); - if (!entry) { - return; - } - files[filePath] = entry; + const optionsIndexes = new Map(); + for (let index = 0; index < options.length; index++) { + optionsIndexes.set(options[index], index); + } + + const fileOffsets = new Map(); + const optionsUseCounts = new Array(options.length).fill(0); + for (let offset = 0; offset < files.length; offset += fileEntryWidth) { + const filePath = files[offset] as string; + const optionsIndex = files[offset + optionsIndexOffset] as number; + fileOffsets.set(filePath, offset); + optionsUseCounts[optionsIndex]++; } return { - version: fmtCacheVersion, - namespace: value.namespace, - files, + cache, + fileOffsets, + optionsIndexes, + optionsUseCounts, }; }; @@ -100,43 +117,124 @@ const getTemporaryPath = (filePath: string): string => class FmtCacheStoreImpl implements FmtCacheStore { readonly #filePath: string; readonly #cache: FmtCacheFile; + readonly #fileOffsets: Map; + readonly #optionsIndexes: Map; + readonly #optionsUseCounts: number[]; #savedContent: string | undefined; #changed: boolean; constructor( filePath: string, - cache: FmtCacheFile, + parsed: ParsedFmtCacheFile, savedContent: string | undefined, changed: boolean, ) { this.#filePath = filePath; - this.#cache = cache; + this.#cache = parsed.cache; + this.#fileOffsets = parsed.fileOffsets; + this.#optionsIndexes = parsed.optionsIndexes; + this.#optionsUseCounts = parsed.optionsUseCounts; this.#savedContent = savedContent; this.#changed = changed; } get(filePath: string): FmtCacheEntry | undefined { - return this.#cache.files[filePath]; + const offset = this.#fileOffsets.get(filePath); + if (offset === undefined) { + return; + } + + const { files, options } = this.#cache; + const contentHash = files[offset + contentHashOffset] as string; + const optionsHash = options[files[offset + optionsIndexOffset] as number]; + const state = fmtCacheStates[files[offset + stateOffset] as FmtCacheStateId]; + return [contentHash, optionsHash, state]; } set(filePath: string, entry: FmtCacheEntry): void { - const current = this.#cache.files[filePath]; - if (current?.[0] === entry[0] && current[1] === entry[1] && current[2] === entry[2]) { - return; + const { files, options } = this.#cache; + const [contentHash, optionsHash, state] = entry; + const stateId = fmtCacheStateIds[state]; + const offset = this.#fileOffsets.get(filePath); + + if (offset !== undefined) { + const currentOptionsIndex = files[offset + optionsIndexOffset] as number; + if ( + files[offset + contentHashOffset] === contentHash && + options[currentOptionsIndex] === optionsHash && + files[offset + stateOffset] === stateId + ) { + return; + } + + const optionsIndex = this.#getOrCreateOptionsIndex(optionsHash); + if (currentOptionsIndex !== optionsIndex) { + this.#optionsUseCounts[currentOptionsIndex]--; + this.#optionsUseCounts[optionsIndex]++; + files[offset + optionsIndexOffset] = optionsIndex; + } + files[offset + contentHashOffset] = contentHash; + files[offset + stateOffset] = stateId; + } else { + const optionsIndex = this.#getOrCreateOptionsIndex(optionsHash); + const nextOffset = files.length; + files.push(filePath, contentHash, optionsIndex, stateId); + this.#fileOffsets.set(filePath, nextOffset); + this.#optionsUseCounts[optionsIndex]++; } - this.#cache.files[filePath] = - entry[2] === 'unsupported' - ? [entry[0], entry[1], 'unsupported'] - : [entry[0], entry[1], entry[2]]; this.#changed = true; } + #getOrCreateOptionsIndex(optionsHash: string): number { + const current = this.#optionsIndexes.get(optionsHash); + if (current !== undefined) { + return current; + } + + const index = this.#cache.options.length; + this.#cache.options.push(optionsHash); + this.#optionsIndexes.set(optionsHash, index); + this.#optionsUseCounts.push(0); + return index; + } + + #compactUnusedOptions(): void { + if (!this.#optionsUseCounts.includes(0)) { + return; + } + + const { files, options } = this.#cache; + const nextOptions: string[] = []; + const nextUseCounts: number[] = []; + const remappedIndexes = new Int32Array(options.length).fill(-1); + for (let index = 0; index < options.length; index++) { + const useCount = this.#optionsUseCounts[index]; + if (useCount > 0) { + remappedIndexes[index] = nextOptions.length; + nextOptions.push(options[index]); + nextUseCounts.push(useCount); + } + } + for (let offset = 0; offset < files.length; offset += fileEntryWidth) { + const currentIndex = files[offset + optionsIndexOffset] as number; + files[offset + optionsIndexOffset] = remappedIndexes[currentIndex]; + } + + options.splice(0, options.length, ...nextOptions); + this.#optionsUseCounts.splice(0, this.#optionsUseCounts.length, ...nextUseCounts); + this.#optionsIndexes.clear(); + for (let index = 0; index < options.length; index++) { + this.#optionsIndexes.set(options[index], index); + } + } + async save(): Promise { if (!this.#changed) { return false; } + this.#compactUnusedOptions(); const content = serializeCache(this.#cache); if (content === this.#savedContent) { this.#changed = false; @@ -164,13 +262,13 @@ const loadFmtCacheStore = async (filePath: string, namespace: string): Promise { return false; } return ( - cache.entry[0] === null && + cache.entry[0] === '' && cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported' && hasDottedBasename(file.path) diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 1befd4ce..6e6505aa 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -12,7 +12,8 @@ interface FormatFileTask { cache?: FmtFileCache; } -const hashContent = (content: string | Uint8Array): string => hash('sha256', content, 'hex'); +const hashContent = (content: string | Uint8Array): string => + hash('sha256', content, 'base64url').slice(0, 16); /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv @@ -44,7 +45,7 @@ const formatFile = async ({ if (cache?.entry && cache.entry[1] === cache.optionsHash) { const { entry } = cache; if (entry[2] === 'unsupported') { - if (entry[0] === null) { + if (entry[0] === '') { if (hasDottedBasename(file.path)) { return { status: 'unsupported' }; } @@ -72,7 +73,7 @@ const formatFile = async ({ status: 'unsupported', cacheEntry: [ hasDottedBasename(file.path) - ? null + ? '' : (contentHash ?? hashContent(sourceBuffer ?? readFileSync(file.path))), cache.optionsHash, 'unsupported', diff --git a/packages/rstack/tests/cli/fmt/cache.test.ts b/packages/rstack/tests/cli/fmt/cache.test.ts index 15e51dea..12c112e2 100644 --- a/packages/rstack/tests/cli/fmt/cache.test.ts +++ b/packages/rstack/tests/cli/fmt/cache.test.ts @@ -4,6 +4,27 @@ import { expectWriteSummary, normalizeDuration, setupFmtTest } from './helpers.t const { projectFileExists, readProjectFile, resolveProjectPath, runFmt, writeProjectFile } = setupFmtTest(); +interface SerializedFmtCache { + version: number; + namespace: string; + options: string[]; + files: (string | number)[]; +} + +const readFmtCache = (filePath: string): SerializedFmtCache => + JSON.parse(readProjectFile(filePath)) as SerializedFmtCache; + +const expectSingleCleanEntry = (cache: SerializedFmtCache, filePath: string): void => { + expect(cache.version).toBe(2); + expect(typeof cache.namespace).toBe('string'); + expect(cache.options).toHaveLength(1); + expect(cache.options[0]).toHaveLength(16); + expect(cache.files).toHaveLength(4); + expect(cache.files[0]).toBe(filePath); + expect(cache.files[1]).toEqual(expect.any(String)); + expect(cache.files.slice(2)).toEqual([0, 0]); +}; + test.each([ ['write', []], ['check', ['--check']], @@ -16,12 +37,7 @@ test.each([ expect(result.status).toBe(0); expect(readProjectFile('.rstack/cache/.gitignore')).toBe('*\n'); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ - version: 1, - files: { - 'index.ts': [expect.any(String), expect.any(String), 'clean'], - }, - }); + expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/cache.json'), 'index.ts'); expect(readProjectFile('.rstack/cache/fmt-v1.json')).toBe('legacy'); }); @@ -58,12 +74,7 @@ test.each(['relative', 'absolute'] as const)('uses a %s custom cache location', const result = runFmt(['--cache-location', cacheLocation, 'index.ts']); expect(result.status).toBe(0); - expect(JSON.parse(readProjectFile('custom-cache/v1.json'))).toMatchObject({ - version: 1, - files: { - 'index.ts': [expect.any(String), expect.any(String), 'clean'], - }, - }); + expectSingleCleanEntry(readFmtCache('custom-cache/cache.json'), 'index.ts'); expect(projectFileExists('custom-cache/.gitignore')).toBe(false); expect(projectFileExists('.rstack')).toBe(false); }); @@ -99,26 +110,22 @@ test('uses an explicit config root cache from a subdirectory', () => { expect(result.status).toBe(0); expect(readProjectFile('packages/app/index.ts')).toBe('const value = 1;\n'); - expect(projectFileExists('.rstack/cache/fmt/v1.json')).toBe(true); + expect(projectFileExists('.rstack/cache/fmt/cache.json')).toBe(true); expect(projectFileExists('packages/app/.rstack')).toBe(false); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ - files: { - 'packages/app/index.ts': [expect.any(String), expect.any(String), 'clean'], - }, - }); + expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/cache.json'), 'packages/app/index.ts'); }); test('recovers from a corrupted cache', () => { writeProjectFile('index.ts', 'const value = 1;\n'); const first = runFmt(['--check', 'index.ts']); - writeProjectFile('.rstack/cache/fmt/v1.json', '{'); + writeProjectFile('.rstack/cache/fmt/cache.json', '{'); const second = runFmt(['--check', 'index.ts']); expect(second.status).toBe(0); expect(normalizeDuration(second.stdout)).toBe(normalizeDuration(first.stdout)); expect(second.stderr).toBe(first.stderr); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ version: 1 }); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt/cache.json'))).toMatchObject({ version: 2 }); }); test('formats without a writable cache directory', () => { diff --git a/packages/rstack/tests/fmt/cacheIdentity.test.ts b/packages/rstack/tests/fmt/cacheIdentity.test.ts index e0d48789..933b7229 100644 --- a/packages/rstack/tests/fmt/cacheIdentity.test.ts +++ b/packages/rstack/tests/fmt/cacheIdentity.test.ts @@ -4,10 +4,11 @@ import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; import { expect, test } from 'rstack/test'; import pkgJson from '../../package.json' with { type: 'json' }; import { + cacheHashLength, cacheNamespace, + createCacheHash, createCacheKeyResolver, createOptionsHasher, - sha256, } from '../../src/fmt/cacheIdentity.ts'; import { fmtCacheVersion } from '../../src/fmt/cacheStore.ts'; import type { ResolvedFmtOptions } from '../../src/fmt/types.ts'; @@ -17,7 +18,7 @@ const rootPath = path.join(import.meta.dirname, 'project'); const asOptions = (value: Record): ResolvedFmtOptions => value as ResolvedFmtOptions; -test('creates stable SHA-256 option hashes', () => { +test('creates stable SHA-256-derived option hashes', () => { const hashOptions = createOptionsHasher(); const left: ResolvedFmtOptions = { singleQuote: true, @@ -29,8 +30,8 @@ test('creates stable SHA-256 option hashes', () => { }; expect(hashOptions(left)).toBe(hashOptions(right)); - expect(hashOptions(left)).toHaveLength(64); - expect(sha256('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(hashOptions(left)).toHaveLength(cacheHashLength); + expect(createCacheHash('abc')).toBe('ungWv48Bz-pBQUDe'); }); test('invalidates hashes when final formatter options change', () => { @@ -52,7 +53,7 @@ test('includes plugin fingerprints in option hashes', () => { const first = createOptionsHasher(new Map([[plugin, 'plugin@1']])); const second = createOptionsHasher(new Map([[plugin, 'plugin@2']])); - expect(first({ plugins: [plugin] })).toHaveLength(64); + expect(first({ plugins: [plugin] })).toHaveLength(cacheHashLength); expect(first({ plugins: [new URL(plugin)] })).toBe(first({ plugins: [plugin] })); expect(first({ plugins: [plugin] })).not.toBe(second({ plugins: [plugin] })); }); diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index 713d804d..e6f038b7 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -1,21 +1,32 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from 'rstack/test'; -import { fmtCacheVersion, loadFmtCacheStore, type FmtCacheFile } from '../../src/fmt/cacheStore.ts'; +import { + fmtCacheFileName, + fmtCacheVersion, + loadFmtCacheStore, + type FmtCacheFile, +} from '../../src/fmt/cacheStore.ts'; import { withTempProject } from './helpers.ts'; const namespace = 'test-namespace'; -const firstEntry = ['content-a', 'options-a', 'clean'] as const; -const secondEntry = ['content-b', 'options-b', 'dirty'] as const; -const unsupportedEntry = [null, 'options-c', 'unsupported'] as const; -const hashedUnsupportedEntry = ['content-c', 'options-c', 'unsupported'] as const; +const contentA = 'content-a'; +const contentB = 'content-b'; +const contentC = 'content-c'; +const optionsA = 'options-a'; +const optionsB = 'options-b'; +const optionsC = 'options-c'; +const firstEntry = [contentA, optionsA, 'clean'] as const; +const secondEntry = [contentB, optionsB, 'dirty'] as const; +const unsupportedEntry = ['', optionsC, 'unsupported'] as const; +const hashedUnsupportedEntry = [contentC, optionsC, 'unsupported'] as const; const readCache = (filePath: string): FmtCacheFile => JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile; -test('writes entries that can be loaded by another store', async () => { +test('writes flat entries that can be loaded by another store', async () => { await withTempProject(async (rootPath) => { - const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); + const cachePath = path.join(rootPath, 'cache', fmtCacheFileName); const store = await loadFmtCacheStore(cachePath, namespace); expect(await store.save()).toBe(false); @@ -26,6 +37,25 @@ test('writes entries that can be loaded by another store', async () => { store.set('script', hashedUnsupportedEntry); expect(await store.save()).toBe(true); expect(await store.save()).toBe(false); + expect(readCache(cachePath)).toEqual({ + version: fmtCacheVersion, + namespace, + options: [optionsA, optionsC], + files: [ + 'src/a.ts', + contentA, + 0, + 0, + 'src/unknown.fixture', + '', + 1, + 2, + 'script', + contentC, + 1, + 2, + ], + }); const loaded = await loadFmtCacheStore(cachePath, namespace); expect(loaded.get('src/a.ts')).toEqual(firstEntry); @@ -36,16 +66,14 @@ test('writes entries that can be loaded by another store', async () => { test('preserves unvisited entries and skips unchanged updates', async () => { await withTempProject(async (rootPath) => { - const cachePath = path.join(rootPath, 'fmt-v1.json'); + const cachePath = path.join(rootPath, fmtCacheFileName); writeFileSync( cachePath, `${JSON.stringify({ version: fmtCacheVersion, namespace, - files: { - 'src/a.ts': firstEntry, - 'src/b.ts': secondEntry, - }, + options: [optionsA, optionsB], + files: ['src/a.ts', contentA, 0, 0, 'src/b.ts', contentB, 1, 1], })}\n`, ); @@ -56,34 +84,30 @@ test('preserves unvisited entries and skips unchanged updates', async () => { store.set('src/a.ts', secondEntry); expect(await store.save()).toBe(true); - expect(readCache(cachePath).files).toEqual({ - 'src/a.ts': secondEntry, - 'src/b.ts': secondEntry, + expect(readCache(cachePath)).toEqual({ + version: fmtCacheVersion, + namespace, + options: [optionsB], + files: ['src/a.ts', contentB, 0, 1, 'src/b.ts', contentB, 0, 1], }); }); }); -test('discards invalid data and entries from another namespace', async () => { +test('discards invalid schemas and other namespaces', async () => { await withTempProject(async (rootPath) => { - const cachePath = path.join(rootPath, 'fmt-v1.json'); + const cachePath = path.join(rootPath, fmtCacheFileName); + const validCache = { + version: fmtCacheVersion, + namespace, + options: [optionsA], + files: ['src/a.ts', contentA, 0, 0], + }; const invalidContents = [ '{invalid', - JSON.stringify({ version: 2, namespace, files: {} }), - JSON.stringify({ - version: fmtCacheVersion, - namespace, - files: { 'src/a.ts': ['content', 'options', 'unknown'] }, - }), - JSON.stringify({ - version: fmtCacheVersion, - namespace, - files: { 'src/a.ts': [42, 'options', 'unsupported'] }, - }), - JSON.stringify({ - version: fmtCacheVersion, - namespace, - files: { 'src/a.ts': [null, 'options', 'clean'] }, - }), + JSON.stringify({ ...validCache, version: fmtCacheVersion - 1 }), + JSON.stringify({ version: fmtCacheVersion, namespace, files: [] }), + JSON.stringify({ ...validCache, files: { 'src/a.ts': firstEntry } }), + JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 0] }), ]; for (const content of invalidContents) { @@ -95,9 +119,8 @@ test('discards invalid data and entries from another namespace', async () => { writeFileSync( cachePath, JSON.stringify({ - version: fmtCacheVersion, + ...validCache, namespace: 'old-namespace', - files: { 'src/a.ts': firstEntry }, }), ); const store = await loadFmtCacheStore(cachePath, namespace); @@ -106,14 +129,15 @@ test('discards invalid data and entries from another namespace', async () => { expect(readCache(cachePath)).toEqual({ version: fmtCacheVersion, namespace, - files: {}, + options: [], + files: [], }); }); }); test('does not throw or leave temporary files when persistence fails', async () => { await withTempProject(async (rootPath) => { - const cachePath = path.join(rootPath, 'fmt-v1.json'); + const cachePath = path.join(rootPath, fmtCacheFileName); mkdirSync(cachePath); const store = await loadFmtCacheStore(cachePath, namespace); diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index f9432a94..e78a2ab0 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -2,7 +2,12 @@ import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { expect, test } from 'rstack/test'; -import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts'; +import { + cacheHashLength, + cacheNamespace, + createCacheHash, + createOptionsHasher, +} from '../../src/fmt/cacheIdentity.ts'; import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; import { runFmtFiles } from '../../src/fmt/runner.ts'; import type { FmtCacheContext, FmtFileRequest, FmtMode } from '../../src/fmt/types.ts'; @@ -36,12 +41,12 @@ for (const mode of ['check', 'list-different'] as const) { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('clean.ts')).toEqual([ - sha256(readFileSync(cleanPath)), + createCacheHash(readFileSync(cleanPath)), expect.any(String), 'clean', ]); expect(store.get('dirty.ts')).toEqual([ - sha256(readFileSync(dirtyPath)), + createCacheHash(readFileSync(dirtyPath)), expect.any(String), 'dirty', ]); @@ -79,7 +84,11 @@ test('uses content hashes instead of file metadata', async () => { const secondStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); const secondEntry = secondStore.get('index.ts'); - expect(secondEntry).toEqual([sha256(readFileSync(filePath)), expect.any(String), 'dirty']); + expect(secondEntry).toEqual([ + createCacheHash(readFileSync(filePath)), + expect.any(String), + 'dirty', + ]); expect(secondEntry?.[0]).not.toBe(firstEntry?.[0]); }); }); @@ -101,7 +110,7 @@ test('invalidates entries when final options change', async () => { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('index.ts')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(changed.options), 'dirty', ]); @@ -121,7 +130,7 @@ test('caches unsupported parser results until final options change', async () => processedFileCount: 0, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ - null, + '', createOptionsHasher()(unsupported.options), 'unsupported', ]); @@ -135,7 +144,7 @@ test('caches unsupported parser results until final options change', async () => processedFileCount: 1, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(supported.options), 'dirty', ]); @@ -155,7 +164,7 @@ test('invalidates cached unsupported parser results when content changes without processedFileCount: 0, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(file.options), 'unsupported', ]); @@ -169,7 +178,7 @@ test('invalidates cached unsupported parser results when content changes without processedFileCount: 1, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(file.options), 'dirty', ]); @@ -212,14 +221,14 @@ test('caches only plugins with stable fingerprints', async () => { const firstHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( 'data.fixture', )?.[1]; - expect(firstHash).toHaveLength(64); + expect(firstHash).toHaveLength(cacheHashLength); writePackageJson('2.0.0'); await run([file], 'check', cache); const secondHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( 'data.fixture', )?.[1]; - expect(secondHash).toHaveLength(64); + expect(secondHash).toHaveLength(cacheHashLength); expect(secondHash).not.toBe(firstHash); }); }); @@ -281,12 +290,12 @@ test('write persists clean results for misses and hits', async () => { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('clean.ts')).toEqual([ - sha256(readFileSync(cleanPath)), + createCacheHash(readFileSync(cleanPath)), expect.any(String), 'clean', ]); expect(store.get('dirty.ts')).toEqual([ - sha256(readFileSync(dirtyPath)), + createCacheHash(readFileSync(dirtyPath)), expect.any(String), 'clean', ]); @@ -318,7 +327,7 @@ test('write converts a dirty entry to clean', async () => { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('index.ts')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), expect.any(String), 'clean', ]); diff --git a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts index 71d48662..7b9e3b87 100644 --- a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts +++ b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts @@ -34,7 +34,7 @@ const createCachedUnsupportedFile = async (rootPath: string, fileName: string) = } const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); - store.set(fileName, [null, optionsHash, 'unsupported']); + store.set(fileName, ['', optionsHash, 'unsupported']); await expect(store.save()).resolves.toBe(true); return { cache, file }; diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 6f3631d5..3ef3d3aa 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { readFileSync } from 'node:fs'; import { expect, test } from 'rstack/test'; -import { sha256 } from '../../src/fmt/cacheIdentity.ts'; +import { createCacheHash } from '../../src/fmt/cacheIdentity.ts'; import { formatFile } from '../../src/fmt/worker.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -11,7 +11,7 @@ test('returns cached states before resolving the parser', async () => { const filePath = writeProjectFile(rootPath, 'example.ts', source); const noExtensionPath = writeProjectFile(rootPath, 'script', source); const missingPath = path.join(rootPath, 'missing.unknown'); - const contentHash = sha256(source); + const contentHash = createCacheHash(source); const optionsHash = 'options'; for (const [entry, targetPath, shouldWrite, status] of [ @@ -20,8 +20,8 @@ test('returns cached states before resolving the parser', async () => { [[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'], [[contentHash, optionsHash, 'unsupported'], noExtensionPath, false, 'unsupported'], [[contentHash, optionsHash, 'unsupported'], noExtensionPath, true, 'unsupported'], - [[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'], - [[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'], + [['', optionsHash, 'unsupported'], missingPath, false, 'unsupported'], + [['', optionsHash, 'unsupported'], missingPath, true, 'unsupported'], ] as const) { await expect( formatFile({ @@ -54,13 +54,13 @@ test('does not trust path-only unsupported entries for files without extensions' }, shouldWrite: false, cache: { - entry: [null, 'options', 'unsupported'], + entry: ['', 'options', 'unsupported'], optionsHash: 'options', }, }), ).resolves.toEqual({ status: 'changed', - cacheEntry: [sha256(readFileSync(filePath)), 'options', 'dirty'], + cacheEntry: [createCacheHash(readFileSync(filePath)), 'options', 'dirty'], }); }); }); @@ -81,7 +81,7 @@ test('resolves parser support before reading on a cache miss', async () => { }), ).resolves.toEqual({ status: 'unsupported', - cacheEntry: [null, 'options', 'unsupported'], + cacheEntry: ['', 'options', 'unsupported'], }); }); });