From d5f99c96c75066ad00017c862f05f0991e0fe12a Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 8 Aug 2026 09:32:08 +0800 Subject: [PATCH 1/2] perf(fmt): cache unsupported extensionless files by content --- packages/rstack/src/fmt/cacheStore.ts | 10 ++++-- packages/rstack/src/fmt/pathHelpers.ts | 5 ++- packages/rstack/src/fmt/runner.ts | 10 ++++-- packages/rstack/src/fmt/worker.ts | 33 +++++++++++++----- packages/rstack/tests/fmt/cacheStore.test.ts | 5 ++- packages/rstack/tests/fmt/runnerCache.test.ts | 34 +++++++++++++++++++ .../tests/fmt/runnerWorkerPreflight.test.ts | 25 ++++++++++++++ packages/rstack/tests/fmt/worker.test.ts | 26 ++++++++++++++ website/docs/en/guide/formatting.mdx | 2 +- website/docs/zh/guide/formatting.mdx | 2 +- 10 files changed, 135 insertions(+), 17 deletions(-) diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index cd725df1..dfc47b60 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -8,7 +8,7 @@ const fmtCacheVersion = 1; type FmtCacheState = 'clean' | 'dirty' | 'unsupported'; type FmtCacheEntry = | readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty'] - | readonly [contentHash: null, optionsHash: string, state: 'unsupported']; + | readonly [contentHash: string | null, optionsHash: string, state: 'unsupported']; interface FmtCacheFile { version: typeof fmtCacheVersion; @@ -35,7 +35,9 @@ const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => { } if (value[2] === 'unsupported') { - return value[0] === null ? [null, value[1], value[2]] : undefined; + 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; @@ -124,7 +126,9 @@ class FmtCacheStoreImpl implements FmtCacheStore { } this.#cache.files[filePath] = - entry[2] === 'unsupported' ? [null, entry[1], entry[2]] : [entry[0], entry[1], entry[2]]; + entry[2] === 'unsupported' + ? [entry[0], entry[1], 'unsupported'] + : [entry[0], entry[1], entry[2]]; this.#changed = true; } diff --git a/packages/rstack/src/fmt/pathHelpers.ts b/packages/rstack/src/fmt/pathHelpers.ts index b5d90aaf..1ad48566 100644 --- a/packages/rstack/src/fmt/pathHelpers.ts +++ b/packages/rstack/src/fmt/pathHelpers.ts @@ -16,5 +16,8 @@ const createRelativePathResolver = (rootPath: string): RelativePathResolver => { : path.relative(rootPath, filePath); }; -export { createRelativePathResolver, toPosixPath }; +/** Prettier only inspects a file's shebang when its basename contains no dot. */ +const hasDottedBasename = (filePath: string): boolean => path.basename(filePath).includes('.'); + +export { createRelativePathResolver, hasDottedBasename, toPosixPath }; export type { RelativePathResolver }; diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index 7b65f02a..635b3809 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -1,6 +1,7 @@ import { cacheNamespace, createCacheKeyResolver, createOptionsHasher } from './cacheIdentity.ts'; import { loadFmtCacheStore } from './cacheStore.ts'; import type { FmtCacheEntry, FmtCacheStore } from './cacheStore.ts'; +import { hasDottedBasename } from './pathHelpers.ts'; import type { FmtFileCache, FmtExitCode, @@ -110,11 +111,16 @@ const createFmtFileRunTask = (file: FmtFileRequest, cache?: RunCache): FmtFileRu return { file, key, cache: fileCache }; }; -const isCachedUnsupported = ({ cache }: FmtFileRunTask): boolean => { +const isCachedUnsupported = ({ file, cache }: FmtFileRunTask): boolean => { if (!cache?.entry) { return false; } - return cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported'; + return ( + cache.entry[0] === null && + cache.entry[1] === cache.optionsHash && + cache.entry[2] === 'unsupported' && + hasDottedBasename(file.path) + ); }; /** Converts a formatter outcome into the shared per-file result. */ diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index c48198d6..6599b4cb 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import type { FmtCacheEntry } from './cacheStore.ts'; +import { hasDottedBasename } from './pathHelpers.ts'; import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts'; interface FormatFileTask { @@ -44,13 +45,23 @@ const formatFile = async ({ if (cache?.entry && cache.entry[1] === cache.optionsHash) { const { entry } = cache; if (entry[2] === 'unsupported') { - return { status: 'unsupported' }; - } - - sourceBuffer = readFileSync(file.path); - contentHash = hashContent(sourceBuffer); - if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { - return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; + if (entry[0] === null) { + if (hasDottedBasename(file.path)) { + return { status: 'unsupported' }; + } + } else { + sourceBuffer = readFileSync(file.path); + contentHash = hashContent(sourceBuffer); + if (entry[0] === contentHash) { + return { status: 'unsupported' }; + } + } + } else { + sourceBuffer = readFileSync(file.path); + contentHash = hashContent(sourceBuffer); + if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { + return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; + } } } @@ -60,7 +71,13 @@ const formatFile = async ({ return cache ? { status: 'unsupported', - cacheEntry: [null, cache.optionsHash, 'unsupported'], + cacheEntry: [ + hasDottedBasename(file.path) + ? null + : (contentHash ?? hashContent(sourceBuffer ?? readFileSync(file.path))), + cache.optionsHash, + 'unsupported', + ], } : { status: 'unsupported' }; } diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index fcedc8ce..713d804d 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -8,6 +8,7 @@ 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 readCache = (filePath: string): FmtCacheFile => JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile; @@ -22,12 +23,14 @@ test('writes entries that can be loaded by another store', async () => { store.set('src/a.ts', firstEntry); store.set('src/unknown.fixture', unsupportedEntry); + store.set('script', hashedUnsupportedEntry); expect(await store.save()).toBe(true); expect(await store.save()).toBe(false); const loaded = await loadFmtCacheStore(cachePath, namespace); expect(loaded.get('src/a.ts')).toEqual(firstEntry); expect(loaded.get('src/unknown.fixture')).toEqual(unsupportedEntry); + expect(loaded.get('script')).toEqual(hashedUnsupportedEntry); }); }); @@ -74,7 +77,7 @@ test('discards invalid data and entries from another namespace', async () => { JSON.stringify({ version: fmtCacheVersion, namespace, - files: { 'src/a.ts': ['content', 'options', 'unsupported'] }, + files: { 'src/a.ts': [42, 'options', 'unsupported'] }, }), JSON.stringify({ version: fmtCacheVersion, diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index ba924b7c..efa69cea 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -155,6 +155,40 @@ test('caches unsupported parser results until final options change', async () => }); }); +test('invalidates cached unsupported parser results when extensionless content changes', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'script', 'plain text\n'); + const cache = createCache(rootPath); + const file = createRequest(filePath, {}); + + const first = await run([file], 'check', cache); + expect(first).toEqual({ + exitCode: 2, + files: [], + processedFileCount: 0, + }); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([ + sha256(readFileSync(filePath)), + createOptionsHasher()(file.options), + 'unsupported', + ]); + + await expect(run([file], 'check', cache)).resolves.toEqual(first); + + writeFileSync(filePath, '#!/usr/bin/env node\nconst value=1'); + await expect(run([file], 'check', cache)).resolves.toMatchObject({ + exitCode: 1, + files: [{ path: filePath, status: 'different' }], + processedFileCount: 1, + }); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([ + sha256(readFileSync(filePath)), + createOptionsHasher()(file.options), + 'dirty', + ]); + }); +}); + test('caches only plugins with stable fingerprints', async () => { await withTempProject(async (rootPath) => { const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}'); diff --git a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts index 78bf9d76..bd7b7988 100644 --- a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts +++ b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts @@ -83,3 +83,28 @@ test('does not start the worker pool when every parser result is cached as unsup expect(mocks.createFmtWorkerPoolCalls).toEqual([]); }); }); + +test('starts the worker pool for a path-only unsupported entry on an extensionless file', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'script', 'plain text'); + const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); + const file: FmtFileRequest = { path: filePath, options: {} }; + const optionsHash = createOptionsHasher()(file.options); + if (optionsHash === undefined) { + throw new Error('Expected cacheable formatter options.'); + } + + const store = await loadFmtCacheStore(cachePath, cacheNamespace); + store.set('script', [null, optionsHash, 'unsupported']); + await expect(store.save()).resolves.toBe(true); + + await expect( + runFmtFiles({ + files: [file], + mode: 'check', + cache: { filePath: cachePath, rootPath }, + }), + ).rejects.toThrow('worker startup failed'); + expect(mocks.createFmtWorkerPoolCalls).toEqual([[1, undefined]]); + }); +}); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 11ebf0c4..3091553a 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -48,6 +48,7 @@ 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 extensionlessPath = writeProjectFile(rootPath, 'script', source); const missingPath = path.join(rootPath, 'missing.unknown'); const contentHash = sha256(source); const optionsHash = 'options'; @@ -56,6 +57,8 @@ test('returns cached states before resolving the parser', async () => { [[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'], [[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'], [[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'], + [[contentHash, optionsHash, 'unsupported'], extensionlessPath, false, 'unsupported'], + [[contentHash, optionsHash, 'unsupported'], extensionlessPath, true, 'unsupported'], [[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'], [[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'], ] as const) { @@ -78,6 +81,29 @@ test('returns cached states before resolving the parser', async () => { }); }); +test('does not trust path-only unsupported entries for extensionless files', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'script', '#!/usr/bin/env node\nconst value=1'); + + await expect( + formatFile({ + file: { + path: filePath, + options: {}, + }, + shouldWrite: false, + cache: { + entry: [null, 'options', 'unsupported'], + optionsHash: 'options', + }, + }), + ).resolves.toEqual({ + status: 'changed', + cacheEntry: [sha256(readFileSync(filePath)), 'options', 'dirty'], + }); + }); +}); + test('resolves parser support before reading on a cache miss', async () => { await withTempProject(async (rootPath) => { await expect( diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index d7d76052..7f975351 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## Cache -`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Formatting results use file content and final formatting options, so changing either causes the file to be formatted again. Unsupported parser lookups use the file path and final options because parser inference does not inspect file content. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. +`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Formatting results use file content and final formatting options, so changing either causes the file to be formatted again. Unsupported parser lookups normally use the file path and final options. For filenames without an extension, they also use file content because Prettier may infer a parser from the shebang. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 8a8e5adf..653ccad6 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## 缓存 \{#cache} -`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。格式化结果基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。由于 parser 推断不会读取文件内容,不支持的 parser 查询结果仅基于文件路径和最终选项。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 +`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。格式化结果基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。不支持的 parser 查询结果通常基于文件路径和最终选项。对于没有扩展名的文件,还会基于文件内容,因为 Prettier 可能从 shebang 推断 parser。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 From 294c4b2e4d277439038ac774c9cbba3be26d6438 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 8 Aug 2026 10:33:30 +0800 Subject: [PATCH 2/2] test(fmt): fix spelling in cache tests --- packages/rstack/tests/fmt/runnerCache.test.ts | 2 +- packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts | 2 +- packages/rstack/tests/fmt/worker.test.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index efa69cea..943b225c 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -155,7 +155,7 @@ test('caches unsupported parser results until final options change', async () => }); }); -test('invalidates cached unsupported parser results when extensionless content changes', async () => { +test('invalidates cached unsupported parser results when content changes without an extension', async () => { await withTempProject(async (rootPath) => { const filePath = writeProjectFile(rootPath, 'script', 'plain text\n'); const cache = createCache(rootPath); diff --git a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts index bd7b7988..640fe832 100644 --- a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts +++ b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts @@ -84,7 +84,7 @@ test('does not start the worker pool when every parser result is cached as unsup }); }); -test('starts the worker pool for a path-only unsupported entry on an extensionless file', async () => { +test('starts the worker pool for a path-only unsupported entry without an extension', async () => { await withTempProject(async (rootPath) => { const filePath = writeProjectFile(rootPath, 'script', 'plain text'); const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 3091553a..29b0a901 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -48,7 +48,7 @@ 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 extensionlessPath = writeProjectFile(rootPath, 'script', source); + const noExtensionPath = writeProjectFile(rootPath, 'script', source); const missingPath = path.join(rootPath, 'missing.unknown'); const contentHash = sha256(source); const optionsHash = 'options'; @@ -57,8 +57,8 @@ test('returns cached states before resolving the parser', async () => { [[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'], [[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'], [[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'], - [[contentHash, optionsHash, 'unsupported'], extensionlessPath, false, 'unsupported'], - [[contentHash, optionsHash, 'unsupported'], extensionlessPath, true, 'unsupported'], + [[contentHash, optionsHash, 'unsupported'], noExtensionPath, false, 'unsupported'], + [[contentHash, optionsHash, 'unsupported'], noExtensionPath, true, 'unsupported'], [[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'], [[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'], ] as const) { @@ -81,7 +81,7 @@ test('returns cached states before resolving the parser', async () => { }); }); -test('does not trust path-only unsupported entries for extensionless files', async () => { +test('does not trust path-only unsupported entries for files without extensions', async () => { await withTempProject(async (rootPath) => { const filePath = writeProjectFile(rootPath, 'script', '#!/usr/bin/env node\nconst value=1');