diff --git a/eslint-rules/eslint-require-export-exists.mjs b/eslint-rules/eslint-require-export-exists.mjs index d079392bf2..378dcae61f 100644 --- a/eslint-rules/eslint-require-export-exists.mjs +++ b/eslint-rules/eslint-require-export-exists.mjs @@ -4,9 +4,31 @@ import path from 'node:path' import { Linter } from 'eslint' const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs'] +const MAX_EXPORT_CACHE_SIZE = 2048 const UNKNOWN_EXPORTS = Symbol('UNKNOWN_EXPORTS') const parserLinter = new Linter() +/** + * @typedef {{ + * dev: number + * ino: number + * size: number + * mtimeMs: number + * ctimeMs: number + * exports: Set | typeof UNKNOWN_EXPORTS + * }} ExportCacheEntry + */ + +/** + * @typedef {{ + * filePath: string + * stats: import('node:fs').Stats + * }} ResolvedModule + */ + +/** @type {Map} */ +const exportCache = new Map() + /** * @typedef {import('eslint').Rule.Node} EslintNode */ @@ -41,7 +63,7 @@ function isFirstPartySpecifier (specifier) { * @param {string} importerPath * @param {string} specifier * @param {string} cwd - * @returns {string | undefined} + * @returns {ResolvedModule | undefined} */ function resolveFirstPartyModulePath (importerPath, specifier, cwd) { if (!isFirstPartySpecifier(specifier)) return undefined @@ -55,27 +77,30 @@ function resolveFirstPartyModulePath (importerPath, specifier, cwd) { const resolved = resolveAsFileOrDirectory(basePath) if (!resolved) return undefined - if (!resolved.startsWith(cwd)) return undefined + if (!resolved.filePath.startsWith(cwd)) return undefined return resolved } /** * @param {string} basePath - * @returns {string | undefined} + * @returns {ResolvedModule | undefined} */ function resolveAsFileOrDirectory (basePath) { - if (isFile(basePath)) return basePath + let stats = getFileStats(basePath) + if (stats) return { filePath: basePath, stats } for (const extension of SCRIPT_EXTENSIONS) { const filePath = `${basePath}${extension}` - if (isFile(filePath)) return filePath + stats = getFileStats(filePath) + if (stats) return { filePath, stats } } if (isDirectory(basePath)) { for (const extension of SCRIPT_EXTENSIONS) { const indexPath = path.join(basePath, `index${extension}`) - if (isFile(indexPath)) return indexPath + stats = getFileStats(indexPath) + if (stats) return { filePath: indexPath, stats } } } @@ -84,13 +109,14 @@ function resolveAsFileOrDirectory (basePath) { /** * @param {string} filePath - * @returns {boolean} + * @returns {import('node:fs').Stats | undefined} */ -function isFile (filePath) { +function getFileStats (filePath) { try { - return fs.statSync(filePath).isFile() + const stats = fs.statSync(filePath) + return stats.isFile() ? stats : undefined } catch { - return false + return undefined } } @@ -298,13 +324,50 @@ function collectExportsFromAst (ast) { /** * @param {string} targetFilePath + * @param {import('node:fs').Stats} stats * @returns {Set | typeof UNKNOWN_EXPORTS} */ -function collectExportsFromFile (targetFilePath) { +function collectExportsFromFile (targetFilePath, stats) { + const cached = exportCache.get(targetFilePath) + if ( + cached?.dev === stats.dev && + cached.ino === stats.ino && + cached.size === stats.size && + cached.mtimeMs === stats.mtimeMs && + cached.ctimeMs === stats.ctimeMs + ) { + return cached.exports + } + + let exports if (path.extname(targetFilePath) === '.json') { - return collectExportsFromJsonFile(targetFilePath) + exports = collectExportsFromJsonFile(targetFilePath) + } else { + exports = collectExportsFromScriptFile(targetFilePath) + } + + if (!cached && exportCache.size === MAX_EXPORT_CACHE_SIZE) { + const oldestTargetFilePath = exportCache.keys().next().value + exportCache.delete(oldestTargetFilePath) } + exportCache.set(targetFilePath, { + dev: stats.dev, + ino: stats.ino, + size: stats.size, + mtimeMs: stats.mtimeMs, + ctimeMs: stats.ctimeMs, + exports, + }) + + return exports +} + +/** + * @param {string} targetFilePath + * @returns {Set | typeof UNKNOWN_EXPORTS} + */ +function collectExportsFromScriptFile (targetFilePath) { let source try { source = fs.readFileSync(targetFilePath, 'utf8') @@ -320,7 +383,7 @@ function collectExportsFromFile (targetFilePath) { sourceType: 'script', }, rules: {}, - }, targetFilePath) + }) ast = parserLinter.getSourceCode()?.ast } catch { return UNKNOWN_EXPORTS @@ -371,7 +434,7 @@ export default { create (context) { const currentFile = context.filename const cwd = context.cwd - const exportCache = new Map() + /** @type {Map} */ const resolutionCache = new Map() /** @@ -389,15 +452,11 @@ export default { ) } - const resolvedPath = resolutionCache.get(resolutionCacheKey) - if (!resolvedPath) return undefined + const resolved = resolutionCache.get(resolutionCacheKey) + if (!resolved) return undefined - if (!exportCache.has(resolvedPath)) { - exportCache.set(resolvedPath, collectExportsFromFile(resolvedPath)) - } - - const exports = exportCache.get(resolvedPath) - if (!exports || exports === UNKNOWN_EXPORTS) return undefined + const exports = collectExportsFromFile(resolved.filePath, resolved.stats) + if (exports === UNKNOWN_EXPORTS) return undefined return { specifier, exports } } @@ -424,6 +483,7 @@ export default { return { VariableDeclarator (node) { if ( + node.id.type !== 'ObjectPattern' || node.init?.type !== 'CallExpression' || node.init.callee?.type !== 'Identifier' || node.init.callee.name !== 'require' || @@ -438,13 +498,11 @@ export default { const moduleInfo = getResolvedExportSet(specifier) if (!moduleInfo) return - if (node.id.type === 'ObjectPattern') { - for (const property of node.id.properties) { - if (property.type !== 'Property') continue - if (property.key.type !== 'Identifier') continue + for (const property of node.id.properties) { + if (property.type !== 'Property') continue + if (property.key.type !== 'Identifier') continue - reportIfMissingExport(property.key, specifier, moduleInfo.exports, property.key.name) - } + reportIfMissingExport(property.key, specifier, moduleInfo.exports, property.key.name) } }, } diff --git a/eslint-rules/eslint-require-export-exists.test.mjs b/eslint-rules/eslint-require-export-exists.test.mjs index 07f6fbb2fd..2840780ac4 100644 --- a/eslint-rules/eslint-require-export-exists.test.mjs +++ b/eslint-rules/eslint-require-export-exists.test.mjs @@ -1,6 +1,10 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' -import { RuleTester } from 'eslint' +import { Linter, RuleTester } from 'eslint' +import sinon from 'sinon' import rule from './eslint-require-export-exists.mjs' @@ -22,10 +26,22 @@ ruleTester.run('eslint-require-export-exists', rule, { filename: fixtureConsumerFile, code: 'const { foo, bar } = require("./named-exports")', }, + { + filename: path.join(path.dirname(fixtureConsumerFile), 'second-consumer.js'), + code: 'const { foo, bar } = require("./named-exports")', + }, { filename: fixtureConsumerFile, code: 'const { foo: renamed } = require("./object-exports")', }, + { + filename: fixtureConsumerFile, + code: 'const { "foo": renamed } = require("./object-exports")', + }, + { + filename: fixtureConsumerFile, + code: 'const { ...rest } = require("./object-exports")', + }, { filename: fixtureConsumerFile, code: 'const { foo, bar } = require("./json-exports.json")', @@ -42,6 +58,10 @@ ruleTester.run('eslint-require-export-exists', rule, { filename: fixtureConsumerFile, code: 'const { nope } = require("semver")', }, + { + filename: fixtureConsumerFile, + code: 'const { nope } = require("./does-not-exist")', + }, ], invalid: [ { @@ -79,3 +99,213 @@ ruleTester.run('eslint-require-export-exists', rule, { }, ], }) + +const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'eslint-require-export-exists-')) + +try { + const realTemporaryDirectory = fs.realpathSync(temporaryDirectory) + const projectDirectory = path.join(realTemporaryDirectory, 'project') + const targetFile = path.join(projectDirectory, 'target.js') + const indexDirectory = path.join(projectDirectory, 'directory') + fs.mkdirSync(projectDirectory) + + const linter = new Linter({ cwd: projectDirectory }) + const config = { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + }, + plugins: { + local: { + rules: { + 'require-export-exists': rule, + }, + }, + }, + rules: { + 'local/require-export-exists': 'error', + }, + } + + /** + * @param {string} code + * @param {string} filename + * @returns {import('eslint').Linter.LintMessage[]} + */ + function verify (code, filename) { + return linter.verify(code, config, { filename: path.join(projectDirectory, filename) }) + } + + fs.writeFileSync(targetFile, 'exports.foo = true\n') + + const directStatSync = sinon.spy(fs, 'statSync') + try { + const directMessages = verify('const target = require("./target")', 'direct-consumer.js') + assert.deepStrictEqual(directMessages, []) + assert.strictEqual(directStatSync.withArgs(targetFile).callCount, 0) + } finally { + directStatSync.restore() + } + + const initialTargetStats = fs.statSync(targetFile) + const changedCtimeStats = fs.statSync(targetFile) + changedCtimeStats.dev = initialTargetStats.dev + changedCtimeStats.ino = initialTargetStats.ino + changedCtimeStats.size = initialTargetStats.size + changedCtimeStats.mtimeMs = initialTargetStats.mtimeMs + changedCtimeStats.ctimeMs = initialTargetStats.ctimeMs + 1 + + assert.strictEqual(changedCtimeStats.dev, initialTargetStats.dev) + assert.strictEqual(changedCtimeStats.ino, initialTargetStats.ino) + assert.strictEqual(changedCtimeStats.size, initialTargetStats.size) + assert.strictEqual(changedCtimeStats.mtimeMs, initialTargetStats.mtimeMs) + assert.strictEqual(changedCtimeStats.ctimeMs, initialTargetStats.ctimeMs + 1) + + const statSync = sinon.stub(fs, 'statSync').callThrough() + const targetStatSync = statSync.withArgs(targetFile) + targetStatSync.callThrough() + targetStatSync.onFirstCall().returns(initialTargetStats) + targetStatSync.onSecondCall().returns(initialTargetStats) + targetStatSync.onThirdCall().returns(changedCtimeStats) + + try { + const firstMessages = verify('const { missing } = require("./target")', 'first-consumer.js') + assert.strictEqual(firstMessages.length, 1) + assert.strictEqual(firstMessages[0].messageId, 'missingExport') + + const secondMessages = verify('const { foo } = require("./target")', 'second-consumer.js') + assert.deepStrictEqual(secondMessages, []) + + fs.writeFileSync(targetFile, 'exports.bar = true\n') + + const mutatedMessages = verify('const { bar } = require("./target")', 'updated-consumer.js') + assert.deepStrictEqual(mutatedMessages, []) + } finally { + statSync.restore() + } + + fs.writeFileSync(targetFile, 'module.exports = JSON.parse("{}")\n') + + const unknownMessages = verify('const { anything } = require("./target")', 'unknown-consumer.js') + assert.deepStrictEqual(unknownMessages, []) + + const repeatedUnknownMessages = verify( + 'const { stillAnything } = require("./target")', + 'second-unknown-consumer.js' + ) + assert.deepStrictEqual(repeatedUnknownMessages, []) + + fs.writeFileSync(targetFile, 'exports.\n') + + const malformedMessages = verify('const { anything } = require("./target")', 'malformed-consumer.js') + assert.deepStrictEqual(malformedMessages, []) + + fs.writeFileSync(targetFile, 'exports.final = true\n') + + const finalMessages = verify('const { final } = require("./target")', 'final-consumer.js') + assert.deepStrictEqual(finalMessages, []) + + fs.mkdirSync(indexDirectory) + fs.writeFileSync(path.join(indexDirectory, 'index.cjs'), 'exports.foo = true\n') + + const indexMessages = verify('const { foo } = require("./directory")', 'index-consumer.js') + assert.deepStrictEqual(indexMessages, []) + + const absoluteMessages = verify('const { missing } = require("/directory")', 'absolute-consumer.js') + assert.strictEqual(absoluteMessages.length, 1) + assert.strictEqual(absoluteMessages[0].messageId, 'missingExport') + + fs.writeFileSync(path.join(realTemporaryDirectory, 'outside.js'), 'exports.foo = true\n') + + const outsideMessages = verify('const { missing } = require("../outside")', 'outside-consumer.js') + assert.deepStrictEqual(outsideMessages, []) + + const siblingProjectDirectory = path.join(realTemporaryDirectory, 'sibling-project') + fs.mkdirSync(siblingProjectDirectory) + fs.writeFileSync(path.join(siblingProjectDirectory, 'target.js'), 'exports.sibling = true\n') + const siblingLinter = new Linter({ cwd: siblingProjectDirectory }) + const siblingMessages = siblingLinter.verify( + 'const { final } = require("./target")', + config, + { filename: path.join(siblingProjectDirectory, 'consumer.js') } + ) + assert.strictEqual(siblingMessages.length, 1) + assert.strictEqual(siblingMessages[0].messageId, 'missingExport') + + const evictionTarget = path.join(projectDirectory, 'eviction-target.json') + const evictionTargetsDirectory = path.join(projectDirectory, 'eviction-targets') + fs.mkdirSync(evictionTargetsDirectory) + fs.writeFileSync(evictionTarget, '{"foo":true}\n') + + const { default: evictionRule } = await import('./eslint-require-export-exists.mjs?eviction-test') + const evictionLinter = new Linter({ cwd: projectDirectory }) + const evictionConfig = { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + }, + plugins: { + local: { + rules: { + 'require-export-exists': evictionRule, + }, + }, + }, + rules: { + 'local/require-export-exists': 'error', + }, + } + + /** + * @param {string} code + * @param {string} filename + * @returns {import('eslint').Linter.LintMessage[]} + */ + function verifyEviction (code, filename) { + return evictionLinter.verify(code, evictionConfig, { filename: path.join(projectDirectory, filename) }) + } + + const readFileSync = sinon.spy(fs, 'readFileSync') + try { + const evictionTargetMessages = verifyEviction( + 'const { foo } = require("./eviction-target.json")', + 'eviction-target-consumer.js' + ) + assert.deepStrictEqual(evictionTargetMessages, []) + + for (let index = 0; index < 2047; index++) { + const targetName = `target-${index}.json` + fs.writeFileSync(path.join(evictionTargetsDirectory, targetName), '{"foo":true}\n') + const messages = verifyEviction( + `const { foo } = require("./eviction-targets/${targetName}")`, + `eviction-consumer-${index}.js` + ) + assert.deepStrictEqual(messages, []) + } + + const cachedEvictionTargetMessages = verifyEviction( + 'const { foo } = require("./eviction-target.json")', + 'cached-eviction-target-consumer.js' + ) + assert.deepStrictEqual(cachedEvictionTargetMessages, []) + assert.strictEqual(readFileSync.withArgs(evictionTarget, 'utf8').callCount, 1) + + fs.writeFileSync(path.join(evictionTargetsDirectory, 'target-2047.json'), '{"foo":true}\n') + const overflowMessages = verifyEviction( + 'const { foo } = require("./eviction-targets/target-2047.json")', + 'overflow-consumer.js' + ) + assert.deepStrictEqual(overflowMessages, []) + + const evictedTargetMessages = verifyEviction( + 'const { foo } = require("./eviction-target.json")', + 'evicted-target-consumer.js' + ) + assert.deepStrictEqual(evictedTargetMessages, []) + assert.strictEqual(readFileSync.withArgs(evictionTarget, 'utf8').callCount, 2) + } finally { + readFileSync.restore() + } +} finally { + fs.rmSync(temporaryDirectory, { recursive: true }) +}