publint
Version:
Lint packaging errors
1,169 lines (1,100 loc) • 40.2 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,
} 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 _include that used to filter paths that is packed.
* Mainly for node.js local usage only. So that we lint files that are packed only.
* Currently only used if pkg has no `exports`
* @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) return { messages }
const rootPkg = JSON.parse(rootPkgContent)
const [main, mainPkgPath] = getPublishedField(rootPkg, 'main')
const [module, modulePkgPath] = getPublishedField(rootPkg, 'module')
const [exports, exportsPkgPath] = getPublishedField(rootPkg, 'exports')
// 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',
})
}
// 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',
})
}
}
})
}
/**
* 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',
})
}
})
}
/**
* 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 file existence for browser field
const [browser, browserPkgPath] = getPublishedField(rootPkg, 'browser')
if (browser) {
crawlBrowser(browser, browserPkgPath)
// if the package has both the `browser` and `exports` fields, recommend to use
// the browser condition instead
if (exports) {
if (typeof browser === 'string') {
messages.push({
code: 'USE_EXPORTS_BROWSER',
args: {},
path: browserPkgPath,
type: 'suggestion',
})
} else {
messages.push({
code: 'USE_EXPORTS_OR_IMPORTS_BROWSER',
args: {},
path: browserPkgPath,
type: 'suggestion',
})
}
}
}
if (exports) {
// recursively check exports
crawlExports(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)
}
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') }
} else if (level === 'error') {
return { messages: messages.filter((m) => m.type === 'error') }
}
return { messages }
/**
* @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)) {
messages.push({
code: 'INVALID_REPOSITORY_VALUE',
args: { type: 'shorthand-git-sites' },
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
*/
function crawlBrowser(fieldValue, currentPath) {
if (typeof fieldValue === 'string') {
promiseQueue.push(async () => {
const browserPath = vfs.pathJoin(pkgDir, fieldValue)
await readFile(browserPath, currentPath, ['.js', '/index.js'])
})
} else if (typeof fieldValue === 'object') {
for (const key in fieldValue) {
crawlBrowser(fieldValue[key], currentPath.concat(key))
}
}
}
/**
* @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]
}
/**
* @param {any} exportsValue
* @param {string[]} currentPath
* @param {boolean} isAfterNodeCondition
*/
function crawlExports(
exportsValue,
currentPath,
isAfterNodeCondition = false,
) {
if (typeof exportsValue === 'string') {
promiseQueue.push(async () => {
// warn deprecated subpath mapping
// https://nodejs.org/docs/latest-v16.x/api/packages.html#subpath-folder-mappings
if (exportsValue.endsWith('/')) {
const expectPath = currentPath.map((part) => {
return part.endsWith('/') ? part + '*' : part
})
const expectPathAlreadyExist = !!getPkgPathValue(rootPkg, expectPath)
messages.push({
code: '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' : 'warning',
})
// help fix glob so we can further analyze other issues
exportsValue += '*'
}
// error incorrect exports value
if (!exportsValue.startsWith('./')) {
messages.push({
code: '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) {
messages.push({
code: 'EXPORTS_GLOB_NO_MATCHED_FILES',
args: {},
path: currentPath,
type: 'warning',
})
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 (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`
if (isAbsolutePath(filePath)) {
pq.push(async () => await readFile(filePath, currentPath))
}
continue
}
pq.push(async () => {
// could fail if in !isGlob
const fileContent = await readFile(filePath, currentPath)
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: 'EXPORTS_MODULE_SHOULD_BE_ESM',
args: {},
path: currentPath,
type: 'error',
})
}
return
}
// 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 (isAfterNodeCondition || currentPath.includes('browser')) 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.
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()
})
}
// `exports` could be null to disallow exports of globs from another key
else if (exportsValue) {
const exportsKeys = Object.keys(exportsValue)
// the types export should be the first condition
if ('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.
const precedingKeys = exportsKeys
.slice(0, exportsKeys.indexOf('types'))
.filter((key) => !key.startsWith('types'))
// 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: 'EXPORTS_MODULE_SHOULD_PRECEDE_REQUIRE',
args: {},
path: currentPath.concat('module'),
type: 'error',
})
}
// the default export should be the last condition
if (
'default' in exportsValue &&
exportsKeys[exportsKeys.length - 1] !== 'default'
) {
messages.push({
code: 'EXPORTS_DEFAULT_SHOULD_BE_LAST',
args: {},
path: currentPath.concat('default'),
type: 'error',
})
}
// keep special state of whether the next `crawlExports` 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.
let isKeyAfterNodeCondition = isAfterNodeCondition
for (const key of exportsKeys) {
crawlExports(
exportsValue[key],
currentPath.concat(key),
isKeyAfterNodeCondition,
)
if (key === 'node') {
isKeyAfterNodeCondition = 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) {
messages.push({
code: 'EXPORTS_TYPES_INVALID_FORMAT',
args: {
condition: format,
actualFormat: dtsActualFormat,
expectFormat: dtsExpectFormat,
actualExtension: vfs.getExtName(typesResult.value),
expectExtension: getDtsCodeFormatExtension(dtsExpectFormat),
},
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 {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
if (!startsWithShebang(binContent)) {
messages.push({
code: 'BIN_FILE_NOT_EXECUTABLE',
args: {},
path: currentPath,
type: 'error',
})
}
// Check format of file
const actualFormat = getCodeFormat(binContent)
const expectFormat = await getFilePathFormat(binPath, vfs)
if (
actualFormat !== expectFormat &&
actualFormat !== 'unknown' &&
actualFormat !== 'mixed'
) {
const actualExtension = vfs.getExtName(binPath)
messages.push({
code: isExplicitExtension(actualExtension)
? 'FILE_INVALID_EXPLICIT_FORMAT'
: 'FILE_INVALID_FORMAT',
args: {
actualFormat,
expectFormat,
actualExtension,
expectExtension: getCodeFormatExtension(actualFormat),
},
path: currentPath,
type: 'warning',
})
}
})
} else if (typeof binValue === 'object') {
for (const key in binValue) {
const binPath = currentPath.concat(key)
// Nested commands are not allowed
if (!ensureTypeOfField(binValue[key], ['string'], binPath)) continue
crawlBin(binValue[key], binPath)
}
}
}
}