From e711183b1596c4a79d6bc0404274b1ec56f70e3f Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 6 Aug 2026 22:36:07 +0800 Subject: [PATCH] feat(fmt): cache check and list results --- packages/rstack/src/fmt/runner.ts | 101 +++++++--- packages/rstack/src/fmt/types.ts | 23 +++ packages/rstack/src/fmt/worker.ts | 53 ++++-- packages/rstack/src/fmt/workerPool.ts | 6 +- packages/rstack/tests/fmt/runnerCache.test.ts | 176 ++++++++++++++++++ packages/rstack/tests/fmt/worker.test.ts | 35 +++- 6 files changed, 354 insertions(+), 40 deletions(-) create mode 100644 packages/rstack/tests/fmt/runnerCache.test.ts diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index 0f9c92c..e4f81b5 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -1,4 +1,8 @@ +import { cacheNamespace, createCacheKeyResolver, createOptionsHasher } from './cacheIdentity.ts'; +import { loadFmtCacheStore } from './cacheStore.ts'; +import type { FmtCacheEntry, FmtCacheStore } from './cacheStore.ts'; import type { + FmtFileCache, FmtExitCode, FmtFileRequest, FmtFileResult, @@ -11,6 +15,18 @@ import type { FmtWorkerPool } from './workerPool.ts'; type FormatFile = FmtWorkerPool['formatFile']; type FmtFileOutcome = FmtFileResult | 'unchanged' | 'unsupported'; +interface FmtFileRun { + outcome: FmtFileOutcome; + key?: string; + entry?: FmtCacheEntry; +} + +interface RunCache { + store: FmtCacheStore; + resolveKey: ReturnType; + hashOptions: ReturnType; +} + interface FmtWorkerPoolResult { files: FmtFileResult[]; processedFileCount: number; @@ -32,22 +48,47 @@ const runFmtFile = async ( file: FmtFileRequest, shouldWrite: boolean, formatFile: FormatFile, -): Promise => { - try { - const result = await formatFile(file, shouldWrite); - if (result === 'unchanged' || result === 'unsupported') { - return result; + cache?: RunCache, +): Promise => { + let key: string | undefined; + let fileCache: FmtFileCache | undefined; + + if (cache) { + key = cache.resolveKey(file.path); + if (key !== undefined) { + const optionsHash = cache.hashOptions(file.options); + if (optionsHash === undefined) { + key = undefined; + } else { + fileCache = { + entry: cache.store.get(key), + optionsHash, + }; + } } + } - return { - path: file.path, - status: shouldWrite ? 'written' : 'different', - }; + try { + const result = await formatFile(file, shouldWrite, fileCache); + const outcome: FmtFileOutcome = + result.status === 'changed' + ? { + path: file.path, + status: shouldWrite ? 'written' : 'different', + } + : result.status; + + if (key !== undefined && result.cacheEntry) { + return { outcome, key, entry: result.cacheEntry }; + } + return { outcome }; } catch (error) { return { - path: file.path, - status: 'error', - error, + outcome: { + path: file.path, + status: 'error', + error, + }, }; } }; @@ -57,7 +98,8 @@ const runPriorityFmtFiles = async ( files: FmtFileRequest[], shouldWrite: boolean, formatFile: FormatFile, -): Promise => { + cache?: RunCache, +): Promise => { const priority: number[] = []; const rest: number[] = []; @@ -67,9 +109,9 @@ const runPriorityFmtFiles = async ( const order = priority.concat(rest); const outcomes = await Promise.all( - order.map((index) => runFmtFile(files[index], shouldWrite, formatFile)), + order.map((index) => runFmtFile(files[index], shouldWrite, formatFile, cache)), ); - const results = new Array(files.length); + const results = new Array(files.length); for (let index = 0; index < order.length; index++) { results[order[index]] = outcomes[index]; } @@ -81,6 +123,7 @@ const runFmtFilesInWorkerPool = async ( files: FmtFileRequest[], shouldWrite: boolean, maxWorkers?: number, + cache?: RunCache, ): Promise => { const { createFmtWorkerPool } = await import('./workerPool.ts'); const workerPool = await createFmtWorkerPool(files.length, maxWorkers); @@ -88,21 +131,24 @@ const runFmtFilesInWorkerPool = async ( try { const results = workerPool.workerCount >= minPriorityWorkers - ? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile) + ? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile, cache) : await Promise.all( - files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile)), + files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile, cache)), ); const processedFiles: FmtFileResult[] = []; let processedFileCount = 0; - for (const result of results) { - if (result === 'unsupported') { + for (const { outcome, key, entry } of results) { + if (key !== undefined && entry) { + cache?.store.set(key, entry); + } + if (outcome === 'unsupported') { continue; } processedFileCount++; - if (result !== 'unchanged') { - processedFiles.push(result); + if (outcome !== 'unchanged') { + processedFiles.push(outcome); } } @@ -133,12 +179,23 @@ const runFmtFiles = async ({ files, mode, maxWorkers, + cache, }: RunFmtFilesOptions): Promise => { const shouldWrite = mode === 'write'; + let runCache: RunCache | undefined; + if (files.length > 0 && cache && !shouldWrite) { + runCache = { + store: await loadFmtCacheStore(cache.filePath, cacheNamespace), + resolveKey: createCacheKeyResolver(cache.rootPath), + hashOptions: createOptionsHasher(), + }; + } + const result = files.length === 0 ? { files: [], processedFileCount: 0 } - : await runFmtFilesInWorkerPool(files, shouldWrite, maxWorkers); + : await runFmtFilesInWorkerPool(files, shouldWrite, maxWorkers, runCache); + await runCache?.store.save().catch(() => false); return { ...result, diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index e93a4bd..f3cabb9 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -1,4 +1,5 @@ import type { Config as PrettierConfig, Options as PrettierOptions } from 'prettier'; +import type { FmtCacheEntry } from './cacheStore.ts'; /** Plugin objects cannot cross worker boundaries and are not planned for support. */ type FmtPluginSpecifier = string | URL; @@ -71,6 +72,23 @@ interface FmtFileRequest { options: ResolvedFmtOptions; } +interface FmtCacheContext { + /** Persistent cache file to load and update. */ + filePath: string; + /** Root used to create portable per-file cache keys. */ + rootPath: string; +} + +interface FmtFileCache { + entry: FmtCacheEntry | undefined; + optionsHash: string; +} + +interface FmtWorkerResult { + status: 'changed' | 'unchanged' | 'unsupported'; + cacheEntry?: FmtCacheEntry; +} + type FmtMode = 'write' | 'check' | 'list-different'; type FmtExitCode = 0 | 1 | 2; @@ -81,6 +99,8 @@ interface RunFmtFilesOptions { mode: FmtMode; /** Maximum number of formatting workers. */ maxWorkers?: number; + /** Internal persistent cache context. Currently used only by check and list modes. */ + cache?: FmtCacheContext; } interface SuccessfulFmtFileResult { @@ -106,14 +126,17 @@ interface FmtRunResult { export type { DiscoverFmtFilesOptions, + FmtCacheContext, FmtConfig, FmtConfigDefinition, FmtExitCode, FmtFileResult, FmtFileRequest, + FmtFileCache, FmtMode, FmtPluginSpecifier, FmtRunResult, + FmtWorkerResult, ResolvedFmtConfig, ResolvedFmtOptions, RunFmtFilesOptions, diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 1b861e7..d695bc5 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -1,39 +1,64 @@ // Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md +import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; -import { formatFmtSource } from './format.ts'; -import type { FmtFileRequest } from './types.ts'; - -type FormatFileResult = 'changed' | 'unchanged' | 'unsupported'; +import type { FmtCacheEntry } from './cacheStore.ts'; +import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts'; interface FormatFileTask { file: FmtFileRequest; shouldWrite: boolean; + cache?: FmtFileCache; } +const hashContent = (content: Uint8Array): string => + createHash('sha256').update(content).digest('hex'); + /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv * scheduling overhead. This prioritizes throughput over crash-safe replacement. */ -const formatFile = async ({ file, shouldWrite }: FormatFileTask): Promise => { - const result = await formatFmtSource(file, () => readFileSync(file.path, 'utf8')); +const formatFile = async ({ + file, + shouldWrite, + cache, +}: FormatFileTask): Promise => { + let source: string | undefined; + let contentHash: string | undefined; + + if (cache && !shouldWrite) { + const content = readFileSync(file.path); + contentHash = hashContent(content); + source = content.toString('utf8'); + + const { entry, optionsHash } = cache; + if (entry?.[0] === contentHash && entry[1] === optionsHash) { + return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; + } + } + + const { formatFmtSource } = await import('./format.ts'); + const result = await formatFmtSource(file, () => (source ??= readFileSync(file.path, 'utf8'))); if (result.status === 'unsupported') { - return 'unsupported'; + return { status: 'unsupported' }; } - const { source, formatted } = result; - if (source === formatted) { - return 'unchanged'; + const unchanged = result.source === result.formatted; + + if (!unchanged && shouldWrite) { + writeFileSync(file.path, result.formatted, 'utf8'); } - if (shouldWrite) { - writeFileSync(file.path, formatted, 'utf8'); + const status = unchanged ? 'unchanged' : 'changed'; + if (!cache || contentHash === undefined) { + return { status }; } - return 'changed'; + const cacheEntry: FmtCacheEntry = [contentHash, cache.optionsHash, unchanged ? 'clean' : 'dirty']; + return { status, cacheEntry }; }; -/** Confirms that the worker module and its runtime dependencies are ready. */ +/** Confirms that the worker module is ready. Formatter dependencies load only on a cache miss. */ const initializeFmtWorker = (): true => true; export { formatFile, initializeFmtWorker }; diff --git a/packages/rstack/src/fmt/workerPool.ts b/packages/rstack/src/fmt/workerPool.ts index 155f4e6..e350a14 100644 --- a/packages/rstack/src/fmt/workerPool.ts +++ b/packages/rstack/src/fmt/workerPool.ts @@ -2,7 +2,7 @@ import { availableParallelism } from 'node:os'; import Tinypool from 'tinypool'; -import type { FmtFileRequest } from './types.ts'; +import type { FmtFileCache, FmtFileRequest } from './types.ts'; type FmtWorkerMethods = typeof import('./worker.ts'); @@ -11,6 +11,7 @@ interface FmtWorkerPool { formatFile: ( file: FmtFileRequest, shouldWrite: boolean, + cache?: FmtFileCache, ) => ReturnType; terminate: () => Promise; } @@ -57,7 +58,8 @@ const createFmtWorkerPool = async ( return { workerCount, - formatFile: (file, shouldWrite) => pool.run({ file, shouldWrite }, { name: 'formatFile' }), + formatFile: (file, shouldWrite, cache) => + pool.run({ file, shouldWrite, cache }, { name: 'formatFile' }), terminate: () => pool.destroy(), }; }; diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts new file mode 100644 index 0000000..b41edb1 --- /dev/null +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -0,0 +1,176 @@ +import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts'; +import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; +import { runFmtFiles } from '../../src/fmt/runner.ts'; +import type { + FmtCacheContext, + FmtFileRequest, + FmtMode, + ResolvedFmtOptions, +} from '../../src/fmt/types.ts'; +import { withTempProject } from './helpers.ts'; + +const createRequest = ( + filePath: string, + options: ResolvedFmtOptions = { parser: 'typescript' }, +): FmtFileRequest => ({ + path: filePath, + options, +}); + +const createCache = (rootPath: string): FmtCacheContext => ({ + filePath: path.join(rootPath, 'cache', 'fmt-v1.json'), + rootPath, +}); + +const run = (files: FmtFileRequest[], mode: FmtMode, cache: FmtCacheContext) => + runFmtFiles({ files, mode, cache }); + +for (const mode of ['check', 'list-different'] as const) { + test(`${mode} persists clean and dirty results`, async () => { + await withTempProject(async (rootPath) => { + const cleanPath = path.join(rootPath, 'clean.ts'); + const dirtyPath = path.join(rootPath, 'dirty.ts'); + const cache = createCache(rootPath); + writeFileSync(cleanPath, 'const clean = 1;\n'); + writeFileSync(dirtyPath, 'const dirty=1'); + + const files = [createRequest(cleanPath), createRequest(dirtyPath)]; + const first = await run(files, mode, cache); + + expect(first).toMatchObject({ + exitCode: 1, + files: [{ path: dirtyPath, status: 'different' }], + processedFileCount: 2, + }); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('clean.ts')).toEqual([ + sha256(readFileSync(cleanPath)), + expect.any(String), + 'clean', + ]); + expect(store.get('dirty.ts')).toEqual([ + sha256(readFileSync(dirtyPath)), + expect.any(String), + 'dirty', + ]); + + await expect(run(files, mode, cache)).resolves.toMatchObject(first); + }); + }); +} + +test('uses content hashes instead of file metadata', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'index.ts'); + const cache = createCache(rootPath); + const timestamp = new Date('2020-01-01T00:00:00.000Z'); + const clean = 'const value = 1;\n'; + const dirty = 'const value= 1;\n'; + writeFileSync(filePath, clean); + utimesSync(filePath, timestamp, timestamp); + + await run([createRequest(filePath)], 'check', cache); + const firstStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); + const firstEntry = firstStore.get('index.ts'); + + writeFileSync(filePath, dirty); + utimesSync(filePath, timestamp, timestamp); + expect(statSync(filePath)).toMatchObject({ + mtimeMs: timestamp.getTime(), + size: Buffer.byteLength(clean), + }); + + await expect(run([createRequest(filePath)], 'check', cache)).resolves.toMatchObject({ + exitCode: 1, + files: [{ path: filePath, status: 'different' }], + }); + + 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?.[0]).not.toBe(firstEntry?.[0]); + }); +}); + +test('invalidates entries when final options change', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'index.ts'); + const cache = createCache(rootPath); + writeFileSync(filePath, 'const value = "text";\n'); + + const initial = createRequest(filePath, { parser: 'typescript', singleQuote: false }); + await run([initial], 'check', cache); + + const changed = createRequest(filePath, { parser: 'typescript', singleQuote: true }); + await expect(run([changed], 'check', cache)).resolves.toMatchObject({ + exitCode: 1, + files: [{ path: filePath, status: 'different' }], + }); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('index.ts')).toEqual([ + sha256(readFileSync(filePath)), + createOptionsHasher()(changed.options), + 'dirty', + ]); + }); +}); + +test('preserves entries outside the formatted subset', async () => { + await withTempProject(async (rootPath) => { + const firstPath = path.join(rootPath, 'first.ts'); + const secondPath = path.join(rootPath, 'second.ts'); + const cache = createCache(rootPath); + writeFileSync(firstPath, 'const first = 1;\n'); + writeFileSync(secondPath, 'const second = 2;\n'); + + await run([createRequest(firstPath), createRequest(secondPath)], 'check', cache); + const firstStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); + const secondEntry = firstStore.get('second.ts'); + + writeFileSync(firstPath, 'const first=1'); + await run([createRequest(firstPath)], 'check', cache); + + const secondStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(secondStore.get('second.ts')).toEqual(secondEntry); + }); +}); + +test('does not cache formatting errors', async () => { + await withTempProject(async (rootPath) => { + const validPath = path.join(rootPath, 'valid.ts'); + const invalidPath = path.join(rootPath, 'invalid.ts'); + const cache = createCache(rootPath); + writeFileSync(validPath, 'const valid = 1;\n'); + writeFileSync(invalidPath, 'const invalid = ;'); + + await run([createRequest(validPath)], 'check', cache); + await expect(run([createRequest(invalidPath)], 'check', cache)).resolves.toMatchObject({ + exitCode: 2, + files: [{ path: invalidPath, status: 'error' }], + }); + + const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); + expect(store.get('valid.ts')).toBeDefined(); + expect(store.get('invalid.ts')).toBeUndefined(); + }); +}); + +test('does not apply the cache in write mode yet', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'index.ts'); + const cache = createCache(rootPath); + writeFileSync(filePath, 'const value=1'); + + await expect(run([createRequest(filePath)], 'write', cache)).resolves.toMatchObject({ + exitCode: 0, + files: [{ path: filePath, status: 'written' }], + }); + expect(readFileSync(filePath, 'utf8')).toBe('const value = 1;\n'); + expect(existsSync(cache.filePath)).toBe(false); + }); +}); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 78730aa..7c823d6 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { expect, test } from 'rstack/test'; +import { sha256 } from '../../src/fmt/cacheIdentity.ts'; import { formatFile } from '../../src/fmt/worker.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -17,7 +18,7 @@ test('writes formatted files', async () => { }, shouldWrite: true, }), - ).resolves.toBe('changed'); + ).resolves.toEqual({ status: 'changed' }); expect(readFileSync(filePath, 'utf8')).toBe('const value = 1;\n'); }); @@ -36,8 +37,38 @@ test('infers the parser for an explicitly provided node_modules file', async () }, shouldWrite: false, }), - ).resolves.toBe('changed'); + ).resolves.toEqual({ status: 'changed' }); expect(readFileSync(filePath, 'utf8')).toBe(source); }); }); + +test('returns cached states before resolving the parser', async () => { + await withTempProject(async (rootPath) => { + const source = 'const value=1'; + const filePath = writeProjectFile(rootPath, 'example.ts', source); + const contentHash = sha256(source); + const optionsHash = 'options'; + + for (const [state, status] of [ + ['clean', 'unchanged'], + ['dirty', 'changed'], + ] as const) { + await expect( + formatFile({ + file: { + path: filePath, + options: { + parser: 'unknown-parser', + }, + }, + shouldWrite: false, + cache: { + entry: [contentHash, optionsHash, state], + optionsHash, + }, + }), + ).resolves.toEqual({ status }); + } + }); +});