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..b1ff30a87 --- /dev/null +++ b/scripts/vsh/src/publint/__tests__/cache.test.ts @@ -0,0 +1,61 @@ +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); + }); +}); diff --git a/scripts/vsh/src/publint/index.ts b/scripts/vsh/src/publint/index.ts index d0786737d..62282d27f 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 { readFile, stat } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; import { colors, @@ -51,6 +52,51 @@ 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; +} + +/** + * 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 relativePaths = [...collectRelativePaths(pkgJson)].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 +105,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 +137,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 +234,4 @@ function definePubLintCommand(cac: CAC) { .action(runPublint); } -export { definePubLintCommand }; +export { definePubLintCommand, getPackageHash, readCache };