From 17ed218780e33d9c458b442b2ec07373a938fd7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9E=E5=8F=AF=20=C2=B7=20YU=20KE?= <1798456934@qq.com> Date: Mon, 7 Sep 2026 21:56:51 +0800 Subject: [PATCH] fix(vsh): invalidate publint cache when outputs change (#8373) * fix(vsh): invalidate publint cache when outputs change * fix(vsh): hash wildcard publint outputs --- .../vsh/src/publint/__tests__/cache.test.ts | 94 ++++++++++++++++++ scripts/vsh/src/publint/index.ts | 96 ++++++++++++++++++- 2 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 scripts/vsh/src/publint/__tests__/cache.test.ts diff --git a/scripts/vsh/src/publint/__tests__/cache.test.ts b/scripts/vsh/src/publint/__tests__/cache.test.ts new file mode 100644 index 000000000..bd982a296 --- /dev/null +++ b/scripts/vsh/src/publint/__tests__/cache.test.ts @@ -0,0 +1,94 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { getPackageHash, readCache } from '../index'; + +const tempDirectories: string[] = []; + +afterEach(async () => { + for (const directory of tempDirectories.splice(0)) { + await rm(directory, { force: true, recursive: true }); + } +}); + +describe('publint cache key', () => { + it('recovers from an empty or malformed cache without throwing', async () => { + const cachePath = join(process.cwd(), '.tmp-publint-cache.json'); + tempDirectories.push(cachePath); + await writeFile(cachePath, '{malformed'); + + await expect(readCache(cachePath)).resolves.toEqual({}); + }); + + it('invalidates when a declared output file is created', async () => { + const packageDir = join(process.cwd(), '.tmp-publint-cache-test'); + tempDirectories.push(packageDir); + await mkdir(join(packageDir, 'dist'), { recursive: true }); + + const packageJson = { + exports: { + '.': { + default: './dist/index.mjs', + types: './dist/index.d.ts', + }, + }, + main: './dist/index.mjs', + }; + + const missingOutputHash = await getPackageHash(packageJson, packageDir); + await writeFile(join(packageDir, 'dist/index.mjs'), 'export {}'); + const createdOutputHash = await getPackageHash(packageJson, packageDir); + + expect(createdOutputHash).not.toBe(missingOutputHash); + }); + + it('changes when a declared output file is modified', async () => { + const packageDir = join(process.cwd(), '.tmp-publint-cache-test'); + tempDirectories.push(packageDir); + await mkdir(join(packageDir, 'dist'), { recursive: true }); + const outputPath = join(packageDir, 'dist/index.mjs'); + await writeFile(outputPath, 'export const value = 1'); + + const packageJson = { main: './dist/index.mjs' }; + const initialHash = await getPackageHash(packageJson, packageDir); + await writeFile(outputPath, 'export const value = 100'); + const updatedHash = await getPackageHash(packageJson, packageDir); + + expect(updatedHash).not.toBe(initialHash); + }); + + it('invalidates when a wildcard output file is created', async () => { + const packageDir = join(process.cwd(), '.tmp-publint-cache-test'); + tempDirectories.push(packageDir); + await mkdir(join(packageDir, 'dist'), { recursive: true }); + + const packageJson = { + exports: { + '.': './dist/*.mjs', + }, + }; + + const missingOutputHash = await getPackageHash(packageJson, packageDir); + await writeFile(join(packageDir, 'dist/index.mjs'), 'export {}'); + const createdOutputHash = await getPackageHash(packageJson, packageDir); + + expect(createdOutputHash).not.toBe(missingOutputHash); + }); + + it('changes when a wildcard output file is modified', async () => { + const packageDir = join(process.cwd(), '.tmp-publint-cache-test'); + tempDirectories.push(packageDir); + await mkdir(join(packageDir, 'dist'), { recursive: true }); + const outputPath = join(packageDir, 'dist/index.mjs'); + await writeFile(outputPath, 'export const value = 1'); + + const packageJson = { exports: { '.': './dist/*.mjs' } }; + const initialHash = await getPackageHash(packageJson, packageDir); + await writeFile(outputPath, 'export const value = 100'); + const updatedHash = await getPackageHash(packageJson, packageDir); + + expect(updatedHash).not.toBe(initialHash); + }); +}); diff --git a/scripts/vsh/src/publint/index.ts b/scripts/vsh/src/publint/index.ts index d0786737d..6ba0581d8 100644 --- a/scripts/vsh/src/publint/index.ts +++ b/scripts/vsh/src/publint/index.ts @@ -1,7 +1,8 @@ import type { CAC } from 'cac'; import type { Result } from 'publint'; -import { basename, dirname, join } from 'node:path'; +import { glob, readFile, stat } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; import { colors, @@ -51,6 +52,85 @@ async function getLintFiles(files: string[] = []) { return lintFiles; } +function collectRelativePaths(value: unknown, paths = new Set()) { + if (typeof value === 'string' && value.startsWith('./')) { + paths.add(value); + } else if (Array.isArray(value)) { + for (const item of value) { + collectRelativePaths(item, paths); + } + } else if (value && typeof value === 'object') { + for (const item of Object.values(value)) { + collectRelativePaths(item, paths); + } + } + return paths; +} + +/** + * Expand wildcard package targets before collecting their filesystem state. + * Keep an unmatched pattern so creating its first match also invalidates the + * cache. + */ +async function expandRelativePath(relativePath: string, pkgDir: string) { + if (!relativePath.includes('*')) { + return [relativePath]; + } + + const matches: string[] = []; + try { + for await (const match of glob(relativePath, { cwd: pkgDir })) { + matches.push(match); + } + } catch { + return [relativePath]; + } + + if (matches.length === 0) { + return [relativePath]; + } + + return matches.map((match) => + match.startsWith('./') ? match : `./${match}`, + ); +} + +/** + * Include referenced package files in the cache key. Publint checks the + * filesystem as well as package.json, so a build can change its result without + * changing the package metadata. + */ +async function getPackageHash( + pkgJson: Record, + pkgDir: string, +) { + const declaredPaths = [...collectRelativePaths(pkgJson)]; + const expandedPaths = await Promise.all( + declaredPaths.map((relativePath) => + expandRelativePath(relativePath, pkgDir), + ), + ); + const relativePaths = expandedPaths.flat().toSorted(); + const fileStates = await Promise.all( + relativePaths.map(async (relativePath) => { + const targetPath = resolve(pkgDir, relativePath); + try { + const target = await stat(targetPath); + return [ + relativePath, + true, + target.isDirectory(), + target.size, + target.mtimeMs, + ] as const; + } catch { + return [relativePath, false] as const; + } + }), + ); + return generatorContentHash(JSON.stringify({ package: pkgJson, fileStates })); +} + function getCacheFile() { const root = findMonorepoRoot(); return join(root, CACHE_FILE); @@ -59,7 +139,14 @@ function getCacheFile() { async function readCache(cacheFile: string) { try { await ensureFile(cacheFile); - return await readJSON(cacheFile); + const content = await readFile(cacheFile, 'utf8'); + if (!content.trim()) { + return {}; + } + const cache = JSON.parse(content); + return cache && typeof cache === 'object' && !Array.isArray(cache) + ? cache + : {}; } catch { return {}; } @@ -84,8 +171,7 @@ async function runPublint(files: string[], { check }: PubLintCommandOptions) { Reflect.deleteProperty(pkgJson, 'dependencies'); Reflect.deleteProperty(pkgJson, 'devDependencies'); Reflect.deleteProperty(pkgJson, 'peerDependencies'); - const content = JSON.stringify(pkgJson); - const hash = generatorContentHash(content); + const hash = await getPackageHash(pkgJson, dirname(file)); const publintResult: Result = cache?.[file]?.hash === hash @@ -182,4 +268,4 @@ function definePubLintCommand(cac: CAC) { .action(runPublint); } -export { definePubLintCommand }; +export { definePubLintCommand, getPackageHash, readCache };