From 91f4c0094b025e9bd26765e00b4a11af9bc19491 Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 6 Aug 2026 22:12:09 +0800 Subject: [PATCH] feat(fmt): add cache identity helpers --- packages/rstack/package.json | 1 + packages/rstack/rslib.config.ts | 2 + packages/rstack/src/fmt/cacheIdentity.ts | 55 ++++++++++++ .../rstack/tests/fmt/cacheIdentity.test.ts | 87 +++++++++++++++++++ pnpm-lock.yaml | 11 +++ pnpm-workspace.yaml | 1 + 6 files changed, 157 insertions(+) create mode 100644 packages/rstack/src/fmt/cacheIdentity.ts create mode 100644 packages/rstack/tests/fmt/cacheIdentity.test.ts diff --git a/packages/rstack/package.json b/packages/rstack/package.json index f16ec98b..0bd3b037 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -82,6 +82,7 @@ "@rstest/adapter-rslib": "catalog:", "@types/micromatch": "catalog:", "@types/node": "catalog:", + "fast-json-stable-stringify": "catalog:", "ignore": "catalog:", "import-meta-resolve": "catalog:", "is-binary-path": "catalog:", diff --git a/packages/rstack/rslib.config.ts b/packages/rstack/rslib.config.ts index 1449f5fe..bf5cf0ec 100644 --- a/packages/rstack/rslib.config.ts +++ b/packages/rstack/rslib.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from '@rslib/core'; +import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; import pkgJson from './package.json' with { type: 'json' }; const fullyMinifiedChunks = /(?:fmt(?:Plugins)?|sortPackageJsonPlugin|staged)\.js$/; @@ -22,6 +23,7 @@ export default defineConfig({ fmtWorker: './src/fmt/worker.ts', }, define: { + PRETTIER_VERSION: JSON.stringify(prettierPkgJson.version), RSTACK_VERSION: JSON.stringify(pkgJson.version), }, }, diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts new file mode 100644 index 00000000..824c0cf9 --- /dev/null +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute } from 'node:path'; +import stableStringify from 'fast-json-stable-stringify'; +import { fmtCacheVersion } from './cacheStore.ts'; +import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; +import type { ResolvedFmtOptions } from './types.ts'; + +declare const PRETTIER_VERSION: string; +declare const RSTACK_VERSION: string; + +type CacheKeyResolver = (filePath: string) => string | undefined; +type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; + +const sha256 = (content: string | Uint8Array): string => + createHash('sha256').update(content).digest('hex'); + +/** Identifies formatter behavior shared by all cache entries in this process. */ +const cacheNamespace: string = JSON.stringify([fmtCacheVersion, RSTACK_VERSION, PRETTIER_VERSION]); + +/** Creates project-relative POSIX cache keys without repeating path setup. */ +const createCacheKeyResolver = (rootPath: string): CacheKeyResolver => { + const resolveRelativePath = createRelativePathResolver(rootPath); + + return (filePath) => { + const relativePath = resolveRelativePath(filePath); + return isAbsolute(relativePath) ? undefined : toPosixPath(relativePath); + }; +}; + +/** Hashes final per-file options and memoizes option objects shared by many files. */ +const createOptionsHasher = (): OptionsHasher => { + const hashes = new WeakMap(); + + return (options) => { + const cached = hashes.get(options); + if (cached !== undefined) { + return cached ?? undefined; + } + + let hash: string | undefined; + try { + // A resolved plugin path does not identify the plugin implementation. + if (!options.plugins?.length) { + hash = sha256(stableStringify(options)); + } + } catch { + // Circular or unreadable options cannot be cached. + } + + hashes.set(options, hash ?? null); + return hash; + }; +}; + +export { cacheNamespace, createCacheKeyResolver, createOptionsHasher, sha256 }; diff --git a/packages/rstack/tests/fmt/cacheIdentity.test.ts b/packages/rstack/tests/fmt/cacheIdentity.test.ts new file mode 100644 index 00000000..b1b13f6d --- /dev/null +++ b/packages/rstack/tests/fmt/cacheIdentity.test.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; +import { expect, test } from 'rstack/test'; +import pkgJson from '../../package.json' with { type: 'json' }; +import { + cacheNamespace, + createCacheKeyResolver, + createOptionsHasher, + sha256, +} from '../../src/fmt/cacheIdentity.ts'; +import { fmtCacheVersion } from '../../src/fmt/cacheStore.ts'; +import type { ResolvedFmtOptions } from '../../src/fmt/types.ts'; + +const rootPath = path.join(import.meta.dirname, 'project'); + +const asOptions = (value: Record): ResolvedFmtOptions => + value as ResolvedFmtOptions; + +test('creates stable SHA-256 option hashes', () => { + const hashOptions = createOptionsHasher(); + const left: ResolvedFmtOptions = { + singleQuote: true, + semi: false, + }; + const right: ResolvedFmtOptions = { + semi: false, + singleQuote: true, + }; + + expect(hashOptions(left)).toBe(hashOptions(right)); + expect(hashOptions(left)).toHaveLength(64); + expect(sha256('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); +}); + +test('invalidates hashes when final formatter options change', () => { + const hashOptions = createOptionsHasher(); + const hashes = [ + hashOptions({ singleQuote: false }), + hashOptions({ singleQuote: true }), + hashOptions({ parser: 'typescript' }), + hashOptions({ sortPackageJson: true }), + hashOptions({ singleQuote: true, semi: false }), + ]; + + expect(hashes.every(Boolean)).toBe(true); + expect(new Set(hashes).size).toBe(hashes.length); +}); + +test('bypasses user plugins and unserializable options', () => { + const hashOptions = createOptionsHasher(); + const cyclic: Record = {}; + const unreadable = new Proxy( + {}, + { + get: () => { + throw new Error('unreadable'); + }, + }, + ); + cyclic.self = cyclic; + + expect(hashOptions({ plugins: [path.resolve('plugin.mjs')] })).toBeUndefined(); + expect(hashOptions({ plugins: [pathToFileURL(path.resolve('plugin.mjs'))] })).toBeUndefined(); + + expect(hashOptions(asOptions({ custom: cyclic }))).toBeUndefined(); + expect(hashOptions(asOptions(unreadable))).toBeUndefined(); +}); + +test('includes formatter implementation versions in the namespace', () => { + expect(JSON.parse(cacheNamespace)).toEqual([ + fmtCacheVersion, + pkgJson.version, + prettierPkgJson.version, + ]); +}); + +test('creates config-root-relative POSIX cache keys', () => { + const resolveKey = createCacheKeyResolver(rootPath); + const firstPath = path.join(rootPath, 'src/nested/index.ts'); + const secondPath = path.join(rootPath, 'src/other.ts'); + + expect(resolveKey(firstPath)).toBe('src/nested/index.ts'); + expect(resolveKey(secondPath)).toBe('src/other.ts'); + expect(resolveKey(firstPath)).not.toBe(resolveKey(secondPath)); + expect(resolveKey(path.join(rootPath, '../shared/index.ts'))).toBe('../shared/index.ts'); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de0dfd06..be9bdd9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ catalogs: cspell-ban-words: specifier: ^0.0.4 version: 0.0.4 + fast-json-stable-stringify: + specifier: 2.1.0 + version: 2.1.0 happy-dom: specifier: ^20.11.1 version: 20.11.1 @@ -367,6 +370,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.3 + fast-json-stable-stringify: + specifier: 'catalog:' + version: 2.1.0 ignore: specifier: 'catalog:' version: 7.0.6 @@ -1416,6 +1422,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3153,6 +3162,8 @@ snapshots: extend@3.0.2: {} + fast-json-stable-stringify@2.1.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c724c9bc..a12d531e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,6 +35,7 @@ catalog: '@types/react-dom': '^19.2.4' '@shikijs/transformers': '^4.4.1' 'cspell-ban-words': '^0.0.4' + 'fast-json-stable-stringify': '2.1.0' 'happy-dom': '^20.11.1' 'heading-case': '^1.1.4' ignore: 7.0.6