publint
Version:
Lint packaging errors
1,348 lines (1,261 loc) • 51.3 kB
JavaScript
import {
commonInternalPaths,
invalidJsxExtensions,
knownBrowserishConditions,
licenseFiles,
} from './constants.js'
import {
exportsGlob,
getCodeFormat,
getFilePathFormat,
getCodeFormatExtension,
isExplicitExtension,
createPromiseQueue,
getPublishedField,
objectHasKeyNested,
isFilePathLintable,
isFileContentLintable,
getAdjacentDtsPath,
resolveExports,
isDtsFile,
getDtsFilePathFormat,
getDtsCodeFormatExtension,
getPkgPathValue,
replaceLast,
isRelativePath,
isAbsolutePath,
isGitUrl,
isShorthandRepositoryUrl,
isShorthandGitHubOrGitLabUrl,
isDeprecatedGitHubGitUrl,
startsWithShebang,
objectHasValueNested,
isFilePathRawTs,
hasEsModuleAndExportsDefault,
hasCustomCondition,
getConditionsFromCurrentPath,
} from './utils.js'
/**
* @typedef Vfs
* @property {(path: string) => Promise<string>} readFile
* @property {(path: string) => Promise<string[]>} readDir
* @property {(path: string) => Promise<boolean>} isPathDir
* @property {(path: string) => Promise<boolean>} isPathExist
* @property {(...paths: string[]) => string} pathJoin
* @property {(from: string, to: string) => string} pathRelative
* @property {(path: string) => string} getDirName
* @property {(path: string) => string} getExtName
*/
/**
* Includes internal _packedFiles that used to filter paths that is packed.
* Mainly for node.js local usage only. So that we lint files that are packed only.
* @typedef {Omit<Required<import('../index.d.ts').Options>, 'pack'> & {
* vfs: Vfs,
* _packedFiles?: string[]
* }} Options
*/
/**
* @param {Options} options
* @returns {Promise<import('../index.d.ts').Result>}
*/
export async function core({ pkgDir, vfs, level, strict, _packedFiles }) {
/** @type {import('../index.d.ts').Message[]} */
const messages = []
/**
* A promise queue is created to run all linting tasks in parallel
*/
const promiseQueue = createPromiseQueue()
const rootPkgPath = vfs.pathJoin(pkgDir, 'package.json')
const rootPkgContent = await readFile(rootPkgPath, [])
if (rootPkgContent === false) {
throw new Error(
`[publint] Unable to find package.json at ${pkgDir}. If the \`pack: { files }\` option is used, ` +
`make sure the \`pkgDir\` is set to the root directory of the files.`,
)
}
const rootPkg = JSON.parse(rootPkgContent)
const [main, mainPkgPath] = getPublishedField(rootPkg, 'main')
const [module, modulePkgPath] = getPublishedField(rootPkg, 'module')
const [browser, browserPkgPath] = getPublishedField(rootPkg, 'browser')
const [exports, exportsPkgPath] = getPublishedField(rootPkg, 'exports')
const [imports, importsPkgPath] = getPublishedField(rootPkg, 'imports')
// Check if package published internal tests or config files
if (rootPkg.files == null) {
promiseQueue.push(async () => {
for (const p of commonInternalPaths) {
const internalPath = vfs.pathJoin(pkgDir, p)
if (_packedFiles && _packedFiles.every((f) => !f.startsWith(internalPath))) {
continue
}
if (await vfs.isPathExist(internalPath)) {
messages.push({
code: 'USE_FILES',
args: {},
path: ['name'],
type: 'suggestion',
})
break
}
}
})
}
// Check if has license file but no license field
if (rootPkg.license == null) {
promiseQueue.push(async () => {
const topFiles = await vfs.readDir(pkgDir)
/** @type {string | undefined} */
let matchedLicenseFilePath
for (const f of topFiles) {
if (await vfs.isPathDir(vfs.pathJoin(pkgDir, f))) continue
if (licenseFiles.some((r) => r.test(f))) {
matchedLicenseFilePath = '/' + f
break
}
}
if (matchedLicenseFilePath) {
messages.push({
code: 'USE_LICENSE',
args: { licenseFilePath: matchedLicenseFilePath },
path: ['name'],
type: 'suggestion',
})
}
})
}
// Check dependencies should not link to local files
if (rootPkg.dependencies) {
promiseQueue.push(async () => {
for (const depName in rootPkg.dependencies) {
/** @type {string} */
const depVersion = rootPkg.dependencies[depName]
if (depVersion.startsWith('file:') || depVersion.startsWith('link:')) {
messages.push({
code: 'LOCAL_DEPENDENCY',
args: {},
path: ['dependencies', depName],
type: 'error',
})
}
}
})
}
// Check if "type" field is specified, help Node.js push towards an ESM default future:
// https://nodejs.org/en/blog/release/v20.10.0
if (rootPkg.type == null) {
messages.push({
code: 'USE_TYPE',
args: {},
path: ['name'],
type: 'suggestion',
})
}
// Check if is Node.js package and the "engines.node" field is missing
const [engines, enginesPkgPath] = getPublishedField(rootPkg, 'engines')
if (engines == null || typeof engines !== 'object' || engines.node == null) {
promiseQueue.push(async () => {
/** @type {import('../index.d.ts').Message} */
const msg = {
code: 'USE_ENGINES_NODE',
args: {},
path: engines != null ? enginesPkgPath : ['name'],
type: 'suggestion',
}
// `node` and `require` conditions are treated as a signal CJS code is published, which
// is mostly applicable for Node.js only
if (
exports != null &&
typeof exports === 'object' &&
(objectHasKeyNested(exports, 'node') || objectHasKeyNested(exports, 'require'))
) {
messages.push(msg)
return
}
// `main` isn't always CJS, so only use it as a signal when the file content is CJS.
if (main != null && typeof main === 'string') {
const mainPath = vfs.pathJoin(pkgDir, main)
const mainContent = await readFile(mainPath, undefined, ['.js', '/index.js'])
if (mainContent && getCodeFormat(mainContent) === 'CJS') {
messages.push(msg)
return
}
}
})
}
// Relies on default node resolution
// https://nodejs.org/api/modules.html#all-together
// LOAD_INDEX(X)
if (main == null && module == null && exports == null) {
promiseQueue.push(async () => {
// check index.js only, others aren't our problem
const defaultPath = vfs.pathJoin(pkgDir, 'index.js')
if (await vfs.isPathExist(defaultPath)) {
const defaultContent = await readFile(defaultPath, [])
if (defaultContent === false) return
const actualFormat = getCodeFormat(defaultContent)
const expectFormat = await getFilePathFormat(defaultPath, vfs)
if (
actualFormat !== expectFormat &&
actualFormat !== 'unknown' &&
actualFormat !== 'mixed'
) {
messages.push({
code: 'IMPLICIT_INDEX_JS_INVALID_FORMAT',
args: {
actualFormat,
expectFormat,
},
path: ['name'],
type: 'warning',
})
}
if (expectFormat === 'CJS' && hasEsModuleAndExportsDefault(defaultContent)) {
messages.push({
code: 'CJS_WITH_ESMODULE_DEFAULT_EXPORT',
args: { filePath: '/index.js' },
path: ['name'],
type: 'warning',
})
}
}
})
}
/**
* Rules for main:
* - It's mostly used for CJS
* - It can be used for ESM, but if you're doing so, might as well use exports
*/
if (main != null) {
promiseQueue.push(async () => {
if (!ensureTypeOfField(main, ['string'], mainPkgPath)) return
const mainPath = vfs.pathJoin(pkgDir, main)
const mainContent = await readFile(mainPath, mainPkgPath, ['.js', '/index.js'])
if (mainContent === false) return
if (hasInvalidJsxExtension(main, mainPkgPath)) return
if (!isFilePathLintable(main)) return
const actualFormat = getCodeFormat(mainContent)
const expectFormat = await getFilePathFormat(mainPath, vfs)
if (actualFormat !== expectFormat && actualFormat !== 'unknown' && actualFormat !== 'mixed') {
const actualExtension = vfs.getExtName(mainPath)
messages.push({
code: isExplicitExtension(actualExtension)
? 'FILE_INVALID_EXPLICIT_FORMAT'
: 'FILE_INVALID_FORMAT',
args: {
actualFormat,
expectFormat,
actualExtension,
expectExtension: getCodeFormatExtension(actualFormat),
},
path: mainPkgPath,
type: 'warning',
})
}
if (actualFormat === 'ESM' && exports == null) {
messages.push({
code: 'HAS_ESM_MAIN_BUT_NO_EXPORTS',
args: {},
path: mainPkgPath,
type: 'suggestion',
})
}
// This check only matters if bundlers uses the file.
// If module field or exports field is specified, bundlers will prefer those over main field
if (
module == null &&
exports == null &&
expectFormat === 'CJS' &&
hasEsModuleAndExportsDefault(mainContent)
) {
messages.push({
code: 'CJS_WITH_ESMODULE_DEFAULT_EXPORT',
args: { filePath: '/' + vfs.pathRelative(pkgDir, mainPath) },
path: mainPkgPath,
type: 'warning',
})
}
})
}
/**
* Rules for module:
* - Bundler-specific
* - Is not a way to support dual packages in NodeJS
* - Should be MJS always!!
*/
if (module != null) {
promiseQueue.push(async () => {
if (!ensureTypeOfField(module, ['string'], modulePkgPath)) return
const modulePath = vfs.pathJoin(pkgDir, module)
const moduleContent = await readFile(modulePath, modulePkgPath, ['.js', '/index.js'])
if (moduleContent === false) return
if (hasInvalidJsxExtension(module, modulePkgPath)) return
if (!isFilePathLintable(module)) return
const actualFormat = getCodeFormat(moduleContent)
if (actualFormat === 'CJS') {
messages.push({
code: 'MODULE_SHOULD_BE_ESM',
args: {},
path: modulePkgPath,
type: 'error',
})
}
// TODO: Check valid content too?
if (!exports) {
messages.push({
code: 'HAS_MODULE_BUT_NO_EXPORTS',
args: {},
path: modulePkgPath,
type: 'suggestion',
})
}
})
}
// if main or module is exists, and exports exists, check if there's a root
// entrypoint in exports. it may be mistaken that exports can be used to define
// nested entrypoints only (missing the root entrypoint)
if ((main != null || module != null) && exports != null) {
let hasRootExports = true
if (typeof exports == 'object') {
const exportsKeys = Object.keys(exports)
// an exports object could contain conditions, or paths that maps to other objects.
// we can determine the type of the object by checking one of the keys ([0])
// if it's a path, which we can then proceed to check if it has the root path
if (exportsKeys[0]?.startsWith('.') && !exportsKeys.includes('.')) {
hasRootExports = false
}
}
if (!hasRootExports) {
const mainFields = []
if (main) mainFields.push('main')
if (module) mainFields.push('module')
messages.push({
code: 'EXPORTS_MISSING_ROOT_ENTRYPOINT',
args: { mainFields },
path: exportsPkgPath,
type: 'warning',
})
}
}
// if `repository` field exist, check if the value is valid
// `repository` might be a shorthand string of URL or an object
if ('repository' in rootPkg) {
promiseQueue.push(() => checkRepositoryField(rootPkg.repository))
}
// check file existence for other known package fields
const knownFields = ['types', 'typings', 'jsnext:main', 'jsnext', 'unpkg', 'jsdelivr']
// if has typesVersions field, it complicates `types`/`typings` field resolution a lot.
// for now skip it, but further improvements are tracked at
// https://github.com/publint/publint/issues/42
if (getPublishedField(rootPkg, 'typesVersions')[0]) {
knownFields.splice(0, 2)
}
for (const fieldName of knownFields) {
const [fieldValue, fieldPkgPath] = getPublishedField(rootPkg, fieldName)
if (fieldValue != null && ensureTypeOfField(fieldValue, ['string'], fieldPkgPath)) {
promiseQueue.push(async () => {
const fieldPath = vfs.pathJoin(pkgDir, fieldValue)
const hasContent = (await readFile(fieldPath, fieldPkgPath, ['.js', '/index.js'])) !== false
if (hasContent && (fieldName === 'jsnext:main' || fieldName === 'jsnext')) {
messages.push({
code: 'DEPRECATED_FIELD_JSNEXT',
args: {},
path: fieldPkgPath,
// `module` should be used instead, but if it's already specified, downgrade as a suggestion
// as the jsnext fields are likely for compat only.
type: module ? 'suggestion' : 'warning',
})
}
})
}
}
// Check if package likely targets bundlers and can benefit from sideEffects hinting
if (rootPkg.sideEffects == null) {
const hasBundlerSignals =
module != null ||
browser != null ||
(exports != null &&
typeof exports === 'object' &&
(objectHasKeyNested(exports, 'browser') || objectHasKeyNested(exports, 'module'))) ||
(imports != null &&
typeof imports === 'object' &&
(objectHasKeyNested(imports, 'browser') || objectHasKeyNested(imports, 'module')))
if (hasBundlerSignals) {
messages.push({
code: 'USE_SIDE_EFFECTS',
args: {},
path: ['name'],
type: 'suggestion',
})
}
}
// check file existence for browser field
if (browser) {
crawlBrowser(browser, browserPkgPath, !!exports)
}
if (exports) {
// recursively check exports
crawlExportsOrImports(exports, exportsPkgPath)
// make sure types are exported for moduleResolution bundler
doCheckTypesExported()
} else {
// all files can be accessed. verify them all
promiseQueue.push(async () => {
const files = await exportsGlob(vfs.pathJoin(pkgDir, './*'), vfs, _packedFiles)
const pq = createPromiseQueue()
for (const filePath of files) {
if (hasInvalidJsxExtension(filePath, ['name'], '/' + vfs.pathRelative(pkgDir, filePath)))
continue
if (!isFilePathLintable(filePath)) continue
pq.push(async () => {
const fileContent = await readFile(filePath, [])
if (fileContent === false) return
if (!isFileContentLintable(fileContent)) return
const actualFormat = getCodeFormat(fileContent)
const expectFormat = await getFilePathFormat(filePath, vfs)
if (
actualFormat !== expectFormat &&
actualFormat !== 'unknown' &&
actualFormat !== 'mixed'
) {
// special case where if the file path contains the keyword "browser" or
// "bundler", but it has problems. allow skipping the problem if it's ESM.
const isSafeEsm =
actualFormat === 'ESM' &&
(filePath.includes('browser') || filePath.includes('bundler'))
if (isSafeEsm) return
const actualExtension = vfs.getExtName(filePath)
const expectExtension = getCodeFormatExtension(actualFormat)
// test if the expected extension and file path already exist. if so, skip warning as
// this invalid format file is probably intentional for other use.
// NOTE: only relax this for globbed files, as they're implicitly exported.
const expectFilePath = replaceLast(filePath, actualExtension, expectExtension)
if (await vfs.isPathExist(expectFilePath)) return
messages.push({
code: isExplicitExtension(actualExtension)
? 'FILE_INVALID_EXPLICIT_FORMAT'
: 'FILE_INVALID_FORMAT',
args: {
actualFormat,
expectFormat,
actualExtension,
expectExtension,
actualFilePath: '/' + vfs.pathRelative(pkgDir, filePath),
},
path: ['name'],
type: 'warning',
})
}
})
}
await pq.wait()
})
}
// check file existence for bin field
const [bin, binPkgPath] = getPublishedField(rootPkg, 'bin')
if (bin) {
crawlBin(bin, binPkgPath)
}
// recursively check imports
if (imports && ensureTypeOfField(imports, ['object'], importsPkgPath)) {
crawlExportsOrImports(imports, importsPkgPath, true)
}
// Check if any nested package.json files have "exports" or "imports" fields, which
// Node.js ignores outside the package root (see https://github.com/nodejs/node/issues/58827).
// Some bundlers may still read them which can lead to inconsistent resolution.
promiseQueue.push(async () => {
await crawlNestedPackageJson(pkgDir)
})
await promiseQueue.wait()
if (strict) {
for (const message of messages) {
if (message.type === 'warning') {
message.type = 'error'
}
}
}
if (level === 'warning') {
return {
messages: messages.filter((m) => m.type !== 'suggestion'),
pkg: rootPkg,
}
} else if (level === 'error') {
return {
messages: messages.filter((m) => m.type === 'error'),
pkg: rootPkg,
}
}
return { messages, pkg: rootPkg }
/**
* @param {string} path file path to read
* @param {string[]} [pkgPath] current path that tries to read this file.
* pass `undefined` to prevent error reporting if the file is missing.
* @param {string[]} tryExtensions list of extensions to try before giving up
* @returns {Promise<string | false>}
*/
async function readFile(path, pkgPath = undefined, tryExtensions = []) {
try {
const content = await vfs.readFile(path)
if (pkgPath && _packedFiles && !_packedFiles.includes(path)) {
fileNotPublished(pkgPath)
}
return content
} catch {
for (let ext of tryExtensions) {
// remove duplicated slashes
if (ext[0] === '/' && path[path.length - 1] === '/') {
ext = ext.slice(1)
}
try {
const content = await vfs.readFile(path + ext)
if (pkgPath && _packedFiles && !_packedFiles.includes(path + ext)) {
fileNotPublished(pkgPath)
}
return content
} catch {}
}
if (pkgPath) {
messages.push({
code: 'FILE_DOES_NOT_EXIST',
args: {},
path: pkgPath,
type: 'error',
})
}
return false
}
}
// https://docs.npmjs.com/cli/v10/configuring-npm/package-json#repository
/**
* @param {Record<string, string> | string} repository
*/
async function checkRepositoryField(repository) {
if (!ensureTypeOfField(repository, ['string', 'object'], ['repository'])) return
if (typeof repository === 'string') {
// the string field accepts shorthands only. if this doesn't look like a shorthand,
// and looks like a git URL, recommend using the object form.
if (!isShorthandRepositoryUrl(repository)) {
messages.push({
code: 'INVALID_REPOSITORY_VALUE',
args: { type: 'invalid-string-shorthand' },
path: ['repository'],
type: 'warning',
})
}
} else if (typeof repository === 'object' && repository.url && repository.type === 'git') {
if (!isGitUrl(repository.url)) {
messages.push({
code: 'INVALID_REPOSITORY_VALUE',
args: { type: 'invalid-git-url' },
path: ['repository', 'url'],
type: 'warning',
})
} else if (isDeprecatedGitHubGitUrl(repository.url)) {
messages.push({
code: 'INVALID_REPOSITORY_VALUE',
args: { type: 'deprecated-github-git-protocol' },
path: ['repository', 'url'],
type: 'suggestion',
})
} else if (isShorthandGitHubOrGitLabUrl(repository.url)) {
let fullUrl = repository.url
// handle git@github.com:user/repo -> git+ssh://git@github.com/user/repo
if (!fullUrl.includes('://')) {
fullUrl = 'git+ssh://' + fullUrl.replace(':', '/')
}
if (fullUrl[fullUrl.length - 1] === '/') {
fullUrl = fullUrl.slice(0, -1)
}
if (!fullUrl.startsWith('git+')) {
fullUrl = 'git+' + fullUrl
}
if (!fullUrl.endsWith('.git')) {
fullUrl += '.git'
}
messages.push({
code: 'INVALID_REPOSITORY_VALUE',
args: { type: 'shorthand-git-sites', suggestValue: fullUrl },
path: ['repository', 'url'],
type: 'suggestion',
})
}
}
}
/**
* @param {string[]} pkgPath
*/
function fileNotPublished(pkgPath) {
messages.push({
code: 'FILE_NOT_PUBLISHED',
args: {},
path: pkgPath,
type: 'error',
})
}
/**
* @param {string} filePath
* @param {string[]} currentPath
* @param {string} [globbedFilePath] only needed for globs
*/
function hasInvalidJsxExtension(filePath, currentPath, globbedFilePath) {
const matched = invalidJsxExtensions.find((ext) => filePath.endsWith(ext))
if (matched) {
messages.push({
code: 'FILE_INVALID_JSX_EXTENSION',
args: {
actualExtension: matched,
globbedFilePath,
},
path: currentPath,
type: 'error',
})
return true
}
return false
}
/**
* @param {any} fieldValue
* @param {('string' | 'number' | 'boolean' | 'object')[]} expectTypes
* @param {string[]} pkgPath
*/
function ensureTypeOfField(fieldValue, expectTypes, pkgPath) {
// @ts-expect-error typeof doesn't need to match `expectedTypes` type but TS panics
if (!expectTypes.includes(typeof fieldValue)) {
messages.push({
code: 'FIELD_INVALID_VALUE_TYPE',
args: {
actualType: typeof fieldValue,
expectTypes,
},
path: pkgPath,
type: 'error',
})
return false
}
return true
}
/**
* @param {string | Record<string, any>} fieldValue
* @param {string[]} currentPath
* @param {boolean} hasExports
*/
function crawlBrowser(fieldValue, currentPath, hasExports) {
if (typeof fieldValue === 'string') {
promiseQueue.push(async () => {
const browserPath = vfs.pathJoin(pkgDir, fieldValue)
const browserContent = await readFile(browserPath, currentPath, ['.js', '/index.js'])
if (browserContent === false) return
if (!isFileContentLintable(browserContent)) return
const expectFormat = await getFilePathFormat(browserPath, vfs)
// This check only matters if bundlers uses the file.
// If exports field is specified, bundlers will prefer that over browser field
if (!hasExports && expectFormat === 'CJS' && hasEsModuleAndExportsDefault(browserContent)) {
messages.push({
code: 'CJS_WITH_ESMODULE_DEFAULT_EXPORT',
args: { filePath: '/' + vfs.pathRelative(pkgDir, browserPath) },
path: currentPath,
type: 'warning',
})
}
})
} else if (typeof fieldValue === 'object') {
for (const key in fieldValue) {
if (typeof fieldValue[key] === 'string') {
promiseQueue.push(async () => {
const browserPath = vfs.pathJoin(pkgDir, fieldValue[key])
await readFile(browserPath, currentPath.concat(key), ['.js', '/index.js'])
})
}
}
}
// if the package has both the `browser` and `exports` fields, recommend to use
// the browser condition instead
if (hasExports) {
if (typeof fieldValue === 'string') {
messages.push({
code: 'USE_EXPORTS_BROWSER',
args: {},
path: currentPath,
type: 'suggestion',
})
} else {
messages.push({
code: 'USE_EXPORTS_OR_IMPORTS_BROWSER',
args: {},
path: currentPath,
type: 'suggestion',
})
}
}
}
/**
* @param {string} exportsValue
* @param {string} exportsKey
* @param {Record<string, any>} exports
*/
async function getExportsFiles(exportsValue, exportsKey, exports) {
const exportsPath = isRelativePath(exportsValue)
? vfs.pathJoin(pkgDir, exportsValue)
: exportsValue
const isGlob = exportsValue.includes('*')
return isGlob
? await exportsGlob(exportsPath, vfs, _packedFiles, exportsKey, exports)
: [exportsPath]
}
/**
* @typedef {{
* isAfterNodeCondition: boolean,
* isReachableByImportCondition: boolean,
* }} CrawlExportsOrImportsState
*/
/**
* @param {any} exportsValue
* @param {string[]} currentPath
* @param {boolean} isImports
* @param {CrawlExportsOrImportsState} [state]
*/
function crawlExportsOrImports(exportsValue, currentPath, isImports = false, state) {
if (typeof exportsValue === 'string') {
promiseQueue.push(async () => {
// if value doesn't start with `.` and we're crawling imports, assume
// that the value is an external dependency or built-in modules
if (isImports && !exportsValue.startsWith('.')) {
return
}
// error removed subpath mapping
// https://nodejs.org/docs/latest-v16.x/api/packages.html#subpath-folder-mappings
// https://nodejs.org/docs/latest-v22.x/api/deprecations.html#dep0148-folder-mappings-in-exports-trailing-
if (exportsValue.endsWith('/')) {
const expectPath = currentPath.map((part) => {
return part.endsWith('/') ? part + '*' : part
})
const expectPathAlreadyExist = !!getPkgPathValue(rootPkg, expectPath)
messages.push({
code: isImports
? 'IMPORTS_GLOB_NO_DEPRECATED_SUBPATH_MAPPING'
: 'EXPORTS_GLOB_NO_DEPRECATED_SUBPATH_MAPPING',
args: {
expectPath,
expectValue: exportsValue + '*',
},
path: currentPath,
// if a trailing glob is also specified, that means this key is for backwards compat only.
// lower severity to suggestion instead.
type: expectPathAlreadyExist ? 'suggestion' : 'error',
})
// help fix glob so we can further analyze other issues
exportsValue += '*'
}
// error incorrect exports/imports value
if (!exportsValue.startsWith('./')) {
messages.push({
code: isImports ? 'IMPORTS_VALUE_INVALID' : 'EXPORTS_VALUE_INVALID',
args: {
suggestValue: './' + exportsValue.replace(/^[\/]+/, ''),
},
path: currentPath,
type: 'error',
})
}
const isGlob = exportsValue.includes('*')
const exportsKey = currentPath[1]
const exportsFiles = await getExportsFiles(exportsValue, exportsKey, exports)
if (isGlob && !exportsFiles.length) {
if (!isImports) {
messages.push({
code: 'EXPORTS_GLOB_NO_MATCHED_FILES',
args: {},
path: currentPath,
type: 'warning',
})
}
// NOTE: We do not report `IMPORTS_GLOB_NO_MATCHED_FILES` as it's possible
// for libraries to not use this subpath import if e.g. it was bundled.
return
}
// if the exports value matches a key in `pkg.browser` (meaning it'll be remapped
// if in a browser-ish environment), check if this is a browser-ish environment/condition.
// if so, warn about this conflict as it's often unexpected behaviour.
if (!isImports && typeof browser === 'object' && exportsValue in browser) {
const browserishCondition = knownBrowserishConditions.find((c) => currentPath.includes(c))
if (browserishCondition) {
messages.push({
code: 'EXPORTS_VALUE_CONFLICTS_WITH_BROWSER',
args: {
browserPath: browserPkgPath.concat(exportsValue),
browserishCondition,
},
path: currentPath,
type: 'warning',
})
}
}
const pq = createPromiseQueue()
// TODO: group glob warnings
for (const filePath of exportsFiles) {
if (
hasInvalidJsxExtension(
filePath,
currentPath,
isGlob ? './' + vfs.pathRelative(pkgDir, filePath) : undefined,
)
)
continue
// TODO: maybe improve .ts checks in the future
if (!isFilePathLintable(filePath)) {
// if not lintable, simply check file existence. only if it's an absolute path,
// so we avoid linting strings like `std:lib`. we also skip checking if it has
// custom conditions, as some libraries may use them internally only which means
// the file may not be published.
if (
isAbsolutePath(filePath, pkgDir) &&
!hasCustomCondition(getConditionsFromCurrentPath(currentPath))
) {
// If crawling subpath imports, files may be bundled so it doesn't exist anymore,
// so we pass `undefined` to prevent error reporting.
const readFilePkgPath = isImports ? undefined : currentPath
pq.push(async () => await readFile(filePath, readFilePkgPath))
}
continue
}
pq.push(async () => {
// If crawling subpath imports, files may be bundled so it doesn't exist anymore,
// so we pass `undefined` to prevent error reporting.
const readFilePkgPath = isImports ? undefined : currentPath
// could fail if in !isGlob
const fileContent = await readFile(filePath, readFilePkgPath)
if (fileContent === false) return
if (!isFileContentLintable(fileContent)) return
// the `module` condition is only used by bundlers and must be ESM
if (currentPath.includes('module')) {
const actualFormat = getCodeFormat(fileContent)
if (actualFormat === 'CJS') {
messages.push({
code: isImports ? 'IMPORTS_MODULE_SHOULD_BE_ESM' : 'EXPORTS_MODULE_SHOULD_BE_ESM',
args: {},
path: currentPath,
type: 'error',
})
}
return
}
const expectFormat = await getFilePathFormat(filePath, vfs)
// This check only matters if it can be reached by `import`.
if (
(!state || state?.isReachableByImportCondition) &&
expectFormat === 'CJS' &&
hasEsModuleAndExportsDefault(fileContent)
) {
messages.push({
code: 'CJS_WITH_ESMODULE_DEFAULT_EXPORT',
args: { filePath: '/' + vfs.pathRelative(pkgDir, filePath) },
path: currentPath,
type: 'warning',
})
}
// file format checks isn't required for `browser` condition or exports
// after the node condition, as nodejs doesn't use it, only bundlers do,
// which doesn't care of the format
if (state?.isAfterNodeCondition || currentPath.includes('browser')) return
const actualFormat = getCodeFormat(fileContent)
if (
actualFormat !== expectFormat &&
actualFormat !== 'unknown' &&
actualFormat !== 'mixed'
) {
// special case where if the file path contains the keyword "browser" or
// "bundler", but it has problems. allow skipping the problem if it's ESM.
const isSafeEsm =
actualFormat === 'ESM' &&
(filePath.includes('browser') || filePath.includes('bundler'))
if (isSafeEsm) return
const actualExtension = vfs.getExtName(filePath)
const expectExtension = getCodeFormatExtension(actualFormat)
// test if the expected extension and file path already exist. if so, skip warning as
// this invalid format file is probably intentional for other use.
// NOTE: only relax this for globbed files, as they're implicitly exported.
if (isGlob) {
const expectFilePath = replaceLast(filePath, actualExtension, expectExtension)
if (await vfs.isPathExist(expectFilePath)) return
}
messages.push({
code: isExplicitExtension(actualExtension)
? 'FILE_INVALID_EXPLICIT_FORMAT'
: 'FILE_INVALID_FORMAT',
args: {
actualFormat,
expectFormat,
actualExtension,
expectExtension,
actualFilePath: isGlob ? './' + vfs.pathRelative(pkgDir, filePath) : exportsValue,
},
path: currentPath,
type: 'warning',
})
}
})
}
await pq.wait()
})
} else if (Array.isArray(exportsValue)) {
messages.push({
code: isImports ? 'IMPORTS_FALLBACK_ARRAY_USE' : 'EXPORTS_FALLBACK_ARRAY_USE',
args: {},
path: currentPath,
type: 'warning',
})
for (const key of exportsValue.keys()) {
crawlExportsOrImports(exportsValue[key], currentPath.concat('' + key), isImports, state)
}
}
// `exports` could be null to disallow exports of globs from another key
else if (exportsValue) {
const exportsKeys = Object.keys(exportsValue)
// types should be the first condition
if (!isImports && 'types' in exportsValue && exportsKeys[0] !== 'types') {
// check preceding conditions before the `types` condition, if there are nested
// conditions, check if they also have the `types` condition. If they do, there's
// a good chance those take precedence over this non-first `types` condition, which
// is fine and is usually used as fallback instead. Versioned `types` conditions
// are allowed to precede the `types` condition.
//
// we also skip any exports value of raw ts or tsx files as they also represent types.
const precedingKeys = exportsKeys.slice(0, exportsKeys.indexOf('types')).filter((key) => {
if (key.startsWith('types')) return false
const value = exportsValue[key]
if (typeof value === 'string' && isFilePathRawTs(value)) return false
if (typeof value === 'object' && objectHasValueNested(value, isFilePathRawTs))
return false
return true
})
// TODO: check that versioned types are valid and the ranges don't overlap.
// TODO: check the order of the conditions are correct. For example,
// 1. Nested conditions
// 2. Versioned `types` conditions
// 3. `types` condition
// 4. All other conditions
let isPrecededByNestedTypesCondition = false
for (const key of precedingKeys) {
if (
typeof exportsValue[key] === 'object' &&
objectHasKeyNested(exportsValue[key], 'types')
) {
isPrecededByNestedTypesCondition = true
break
}
}
if (precedingKeys.length > 0 && !isPrecededByNestedTypesCondition) {
messages.push({
code: 'EXPORTS_TYPES_SHOULD_BE_FIRST',
args: {},
path: currentPath.concat('types'),
type: 'error',
})
}
}
// if there is a 'require' and a 'module' condition at the same level,
// then 'module' should always precede 'require'
if (
'module' in exportsValue &&
'require' in exportsValue &&
exportsKeys.indexOf('module') > exportsKeys.indexOf('require')
) {
messages.push({
code: isImports
? 'IMPORTS_MODULE_SHOULD_PRECEDE_REQUIRE'
: 'EXPORTS_MODULE_SHOULD_PRECEDE_REQUIRE',
args: {},
path: currentPath.concat('module'),
type: 'error',
})
}
// the default export/import should be the last condition
if ('default' in exportsValue && exportsKeys[exportsKeys.length - 1] !== 'default') {
messages.push({
code: isImports ? 'IMPORTS_DEFAULT_SHOULD_BE_LAST' : 'EXPORTS_DEFAULT_SHOULD_BE_LAST',
args: {},
path: currentPath.concat('default'),
type: 'error',
})
}
// Only check that imports start with `#` for the first set of keys
const isCurrentPathImports = isImports && currentPath[currentPath.length - 1] === 'imports'
const isImportRequireConditions =
exportsKeys.includes('require') || exportsKeys.includes('import')
// whether the next `crawlExportsOrImports` iterations are after a node condition.
// if there are, we can skip code format check as nodejs doesn't touch them, except bundlers
// which are fine with any format.
// we track this outside of the for loop as we want the state to persist between the top-level keys.
let isAfterNodeCondition = state?.isAfterNodeCondition ?? false
for (const key of exportsKeys) {
/** @type {CrawlExportsOrImportsState} */
const stateForNextLevel = {
isAfterNodeCondition,
isReachableByImportCondition: state?.isReachableByImportCondition ?? true,
}
if (isImportRequireConditions) {
stateForNextLevel.isReachableByImportCondition &&=
key === 'import' || (!exportsKeys.includes('import') && key === 'default')
}
// Check that import starts with `#`
if (isCurrentPathImports && !key.startsWith('#')) {
messages.push({
code: 'IMPORTS_KEY_INVALID',
args: {
suggestKey: '#' + key.replace(/^[\/]+/, ''),
},
path: currentPath.concat(key),
type: 'error',
})
}
crawlExportsOrImports(
exportsValue[key],
currentPath.concat(key),
isImports,
stateForNextLevel,
)
if (key === 'node') {
isAfterNodeCondition = true
}
}
}
}
function doCheckTypesExported() {
if (typeof exports === 'string') {
checkTypesExported()
} else if (typeof exports === 'object') {
const exportsKeys = Object.keys(exports)
if (exportsKeys.length === 0) return
// check if the `exports` directly map to condition keys (doesn't start with '.').
// if so, we work on it directly.
if (!exportsKeys[0].startsWith('.')) {
checkTypesExported()
}
// else this `exports` may have multiple export entrypoints, check for '.'
// TODO: check for other entrypoints, move logic into `crawlExports`
else if ('.' in exports) {
checkTypesExported('.')
}
}
}
/**
* @param {string | undefined} exportsRootKey
*/
function checkTypesExported(exportsRootKey = undefined) {
promiseQueue.push(async () => {
const typesFilePath = await findTypesFilePath(exportsRootKey)
const exportsRootValue = exportsRootKey ? exports[exportsRootKey] : exports
// detect if this package intend to ship types
if (typesFilePath) {
const exportsPath = exportsRootKey ? exportsPkgPath.concat(exportsRootKey) : exportsPkgPath
// keyed strings for seen resolved paths, so we don't trigger duplicate messages for the same thing
const seenResolvedKeys = new Set()
/**
* @param {(string | undefined)[]} conditions
*/
const _resolveExports = (conditions) => {
return resolveExports(
exportsRootValue,
// @ts-expect-error ts still doesn't understand `filter(Boolean)`
conditions.filter(Boolean),
exportsPath,
)
}
// NOTE: got lazy. here we check for the import/require result in different environments
// to make sure we cover possible cases. however, a better way it to resolve the exports
// and scan also the possible environment conditions, and return an array instead.
for (const env of [undefined, 'node', 'browser', 'worker']) {
const importResult = _resolveExports(['import', env])
const requireResult = _resolveExports(['require', env])
const isDualPublish =
importResult && requireResult && importResult.value !== requireResult.value
for (const format of ['import', 'require']) {
// the types resolved result for the corresponding js
const typesResult = _resolveExports(['types', format, env])
if (!typesResult) continue
// cache by the types path to help deduplicate the linting if we've already done so
// for the same environment or format. if it's dual publishing, we want to lint both times
// so we add the `format` to the key here.
const seenKey = typesResult.path.join('.') + (isDualPublish ? format : '')
if (seenResolvedKeys.has(seenKey)) continue
seenResolvedKeys.add(seenKey)
// if path doesn't exist, let the missing file error message take over instead
const typesResolvedPath = vfs.pathJoin(pkgDir, typesResult.value)
if (!(await vfs.isPathExist(typesResolvedPath))) continue
if (isDtsFile(typesResult.value)) {
// if we have resolve to a dts file, it might not be ours because typescript requires
// `.d.mts` and `.d.cts` for esm and cjs (`.js` and nearest type: module behaviour applies).
// check if we're hitting this case :(
const dtsActualFormat = await getDtsFilePathFormat(typesResolvedPath, vfs)
/** @type {'ESM' | 'CJS' | undefined} */
let dtsExpectFormat = undefined
// get the intended format from the conditions without types, e.g. if the adjacent file
// is a CJS file, despite resolving with the "import" condition, make sure the dts format
// is expected to be CJS too.
// only run this if not dual publish since we know dual publish should have both ESM and CJS
// versions of the dts file, and we don't need to be lenient.
// NOTE: could there be setups with CJS code and ESM types? seems a bit weird.
if (!isDualPublish) {
const jsResult = format === 'import' ? importResult : requireResult
if (jsResult) {
const jsResolvedPath = vfs.pathJoin(pkgDir, jsResult.value)
if (await vfs.isPathExist(jsResolvedPath)) {
dtsExpectFormat = await getFilePathFormat(jsResolvedPath, vfs)
}
}
}
// fallback if we can't determine the non types format, we base on the condition instead.
// NOTE: this favours "import" condition over "require" when the library doesn't dual publish
// because we run "import" first in the for loop.
if (dtsExpectFormat == null) {
dtsExpectFormat = format === 'import' ? 'ESM' : 'CJS'
}
if (dtsActualFormat !== dtsExpectFormat) {
// convert ['exports', 'types'] -> ['exports', '<format>', 'types']
// convert ['exports', 'types', 'node'] -> ['exports', 'types', 'node', '<format>']
const expectPath = typesResult.path.slice()
// Sometimes the path already includes the condition, but it's still in an invalid format
if (!expectPath.includes(format)) {
const typesIndex = expectPath.findIndex((p) => p === 'types')
if (typesIndex === expectPath.length - 1) {
expectPath.splice(typesIndex, 0, format)
} else {
expectPath.push(format)
}
}
messages.push({
code: 'EXPORTS_TYPES_INVALID_FORMAT',
args: {
condition: format,
actualFormat: dtsActualFormat,
expectFormat: dtsExpectFormat,
actualExtension: vfs.getExtName(typesResult.value),
expectExtension: getDtsCodeFormatExtension(dtsExpectFormat),
expectPath,
},
path: typesResult.path,
type: 'warning',
})
}
} else {
// adjacent dts file here is always in the correct format
const hasAdjacentDtsFile = await vfs.isPathExist(
vfs.pathJoin(pkgDir, getAdjacentDtsPath(typesResult.value)),
)
// if there's no adjacent dts file, it's likely they don't support moduleResolution: bundler.
// try to provide a warning.
if (!hasAdjacentDtsFile) {
// before we recommend using `typesFilePath` for this export condition, we need to make sure
// it's of a matching format
const dtsActualFormat = await getDtsFilePathFormat(
vfs.pathJoin(pkgDir, typesFilePath),
vfs,
)
const dtsExpectFormat = format === 'import' ? 'ESM' : 'CJS'
// if it's a matching format, we can recommend using the types file for this exports condition too.
// if not, we need to tell them to create a `.d.[mc]ts` file and not use `typesFilePath`.
// this is signalled in `matchingFormat`, where the message handler should check for it.
const isMatchingFormat = dtsActualFormat === dtsExpectFormat
messages.push({
code: 'TYPES_NOT_EXPORTED',
args: {
typesFilePath,
actualExtension: isMatchingFormat ? undefined : vfs.getExtName(typesFilePath),
expectExtension: isMatchingFormat
? undefined
: getDtsCodeFormatExtension(dtsExpectFormat),
},
path: typesResult.path,
type: 'warning',
})
}
}
}
}
}
})
}
/**
* @param {string | undefined} exportsKey
*/
async function findTypesFilePath(exportsKey) {
let typesFilePath
if (exportsKey == null || exportsKey === '.') {
const [types] = getPublishedField(rootPkg, 'types')
const [typings] = getPublishedField(rootPkg, 'typings')
if (types) {
typesFilePath = types
} else if (typings) {
typesFilePath = typings
} else if (await readFile(vfs.pathJoin(pkgDir, './index.d.ts'))) {
typesFilePath = './index.d.ts'
}
} else {
// TODO: handle nested exports key
}
return typesFilePath
}
/**
* @param {string} dir
*/
async function crawlNestedPackageJson(dir) {
const nestedPackageJsonFields = /** @type {const} */ (['exports', 'imports'])
let items
try {
items = await vfs.readDir(dir)
} catch {
return
}
for (const item of items) {
if (item === 'node_modules') continue
const itemPath = vfs.pathJoin(dir, item)
if (await vfs.isPathDir(itemPath)) {
// If packed files are known, skip directories that don't contain
// any published files to reduce unnecessary traversal.
if (_packedFiles && !_packedFiles.some((f) => isPathNestedWithin(f, itemPath))) {
continue
}
await crawlNestedPackageJson(itemPath)
continue
}
if (item !== 'package.json' || itemPath === rootPkgPath) continue
if (_packedFiles && !_packedFiles.includes(itemPath)) continue
let nestedPkg
try {
const content = await vfs.readFile(itemPath)
nestedPkg = JSON.parse(content)
} catch {
continue
}
const filePath = './' + vfs.pathRelative(pkgDir, itemPath)
for (const field of nestedPackageJsonFields) {
if (nestedPkg[field] != null) {
messages.push({
code: 'NESTED_PACKAGE_JSON_FIELD_IGNORED',
args: { field, filePath },
path: ['name'],
type: 'warning',
})
}
}
}
}
/**
* @param {string} childPath
* @param {string} dirPath
*/
function isPathNestedWithin(childPath, dirPath) {
return childPath.startsWith(dirPath + '/') || childPath.startsWith(dirPath + '\\')
}
/**
* @param {any} binValue
* @param {string[]} currentPath
*/
function crawlBin(binValue, currentPath) {
if (typeof binValue === 'string') {
promiseQueue.push(async () => {
const binPath = vfs.pathJoin(pkgDir, binValue)
const binContent = await readFile(binPath, currentPath, ['.js', '/index.js'])
if (binContent === false) return
// Skip checks if file is not lintable
if (!isFilePathLintable(binValue)) return
// Check that file has shebang
i