UNPKG

publint

Version:

Lint packaging errors

505 lines (466 loc) 14 kB
import { lintableFileExtensions } from './constants.js' /** * @typedef {{ * name: string, * main: string, * module: string, * exports: Record<string, string>, * repository: Record<string, string> | string, * type: 'module' | 'commonjs' * }} Pkg */ /** * @typedef {'ESM' | 'CJS' | 'mixed' | 'unknown'} CodeFormat */ // Reference: https://github.com/unjs/mlly/blob/c5ae321725cbabe230c16c315d474c36eee6a30c/src/syntax.ts#L7 const ESM_CONTENT_RE = /([\s;]|^)(import[\w,{}\s*]*from|import\s*['"*{]|export\b\s*(?:[*{]|default|type|function|const|var|let|async function)|import\.meta\b)/m /** * @param {string} code */ export function isCodeEsm(code) { return ESM_CONTENT_RE.test(code) } // Reference: https://github.com/unjs/mlly/blob/c5ae321725cbabe230c16c315d474c36eee6a30c/src/syntax.ts#L15 const CJS_CONTENT_RE = /([\s;]|^)(module.exports\b|exports\.\w|require\s*\(|global\.\w|Object\.(defineProperty|defineProperties|assign)\s*\(\s*exports\b)/m /** * @param {string} code */ export function isCodeCjs(code) { return CJS_CONTENT_RE.test(code) } const MULTILINE_COMMENTS_RE = /\/\*(.|[\r\n])*?\*\//gm const SINGLELINE_COMMENTS_RE = /\/\/.*/g /** * @param {string} code */ export function stripComments(code) { return code .replace(MULTILINE_COMMENTS_RE, '') .replace(SINGLELINE_COMMENTS_RE, '') } // Reference: https://git-scm.com/docs/git-clone#_git_urls and https://github.com/npm/hosted-git-info const GIT_URL_RE = /^(git\+https?|git\+ssh|https?|ssh|git):\/\/(?:[\w._-]+@)?([\w.-]+)(?::([\w\d-]+))?(\/[\w._/-]+)\/?$/ /** * @param {string} url */ export function isGitUrl(url) { return GIT_URL_RE.test(url) } /** * @param {string} url */ export function isShorthandGitHubOrGitLabUrl(url) { const tokens = url.match(GIT_URL_RE) if (tokens) { const host = tokens[2] const path = tokens[4] if (/(github|gitlab)/.test(host)) { return !url.startsWith('git+') || !path.endsWith('.git') } } return false } /** * Reference: https://github.blog/security/application-security/improving-git-protocol-security-github/ * @param {string} url */ export function isDeprecatedGitHubGitUrl(url) { const tokens = url.match(GIT_URL_RE) if (tokens) { const protocol = tokens[1] const host = tokens[2] if (/github/.test(host) && protocol === 'git') { return true } } return false } // Reference: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#repository const SHORTHAND_REPOSITORY_URL_RE = /^(?:(?:github|bitbucket|gitlab):[^/]+\/.+|gist:.+|[^/]+\/[^/]+)$/ /** * @param {string} url */ export function isShorthandRepositoryUrl(url) { return SHORTHAND_REPOSITORY_URL_RE.test(url) } /** * @param {string} code * @returns {CodeFormat} */ export function getCodeFormat(code) { code = stripComments(code) const isEsm = isCodeEsm(code) const isCjs = isCodeCjs(code) // In reality, a file can't have mixed ESM and CJS. It's syntactically incompatible in both environments. // But since we use regex, we can't correct identify ESM and CJS, so when this happens we should bail instead. // TODO: Yak shave a correct implementation. if (isEsm && isCjs) { return 'mixed' } else if (isEsm) { return 'ESM' } else if (isCjs) { return 'CJS' } else { // If we can't determine the format, it's likely that it doesn't import/export and require/exports. // Meaning it's a side-effectful file, which would always match the `format` return 'unknown' } } /** * Handle `exports` glob * @param {string} globStr An absolute glob string that must contain one `*` * @param {import('./core.js').Vfs} vfs * @param {string[]} [packedFiles] * @param {string} [exportsKey] * @param {Record<string, any>} [exports] * @returns {Promise<string[]>} Matched file paths */ export async function exportsGlob( globStr, vfs, packedFiles, exportsKey, exports, ) { /** @type {string[]} */ const filePaths = [] const globStrRe = new RegExp( `^${slash(globStr).split('*').map(escapeRegExp).join('(.+)')}$`, ) // the longest directory that doesn't contain `*` const topDir = globStr.split('*')[0].match(/(.+)[/\\]/)?.[1] // compute an array of regexes that are marked `null` in the `exports` field. // these keys will be matched later on to ensure they are not included in the final result. // note: may need to consider in the future if conditions should be taken into account. /** @type {RegExp[]} */ const excludedExportKeys = [] if (exportsKey && exports) { for (const key in exports) { if (exports[key] === null) { excludedExportKeys.push( new RegExp(`^${key.split('*').map(escapeRegExp).join('(.+)')}$`), ) } } } // TODO: maybe error if no topDir? if (topDir && (await vfs.isPathDir(topDir))) { await scanDir(topDir) } return filePaths /** * @param {string} dirPath */ async function scanDir(dirPath) { // TODO: flatten this function body const items = await vfs.readDir(dirPath) for (const item of items) { const itemPath = vfs.pathJoin(dirPath, item) // ensure the file is within the packed files (if provided) if ( !packedFiles || packedFiles.some((file) => file.startsWith(itemPath)) ) { if (await vfs.isPathDir(itemPath)) { await scanDir(itemPath) } else { const matched = slash(itemPath).match(globStrRe) if (matched) { // if have multiple `*`, all matched should be the same because the key // can only have one `*` if (matched.length > 2) { let allGlobSame = true for (let i = 2; i < matched.length; i++) { if (matched[i] !== matched[1]) { allGlobSame = false break } } if (!allGlobSame) { continue // skip this file because it doesn't match the glob } } // lastly, also make sure that the matched file is not excluded by // exports keys that are marked null. we detect that by replacing the // matched `*` result back to the exports key, and make sure it doesn't // match any of the excludedExportsKeys. if (exportsKey && exports && excludedExportKeys.length) { const replacedExportsKey = exportsKey.replace('*', matched[1]) if ( excludedExportKeys.some((re) => re.test(replacedExportsKey)) ) { continue } } filePaths.push(itemPath) } } } } } } /** * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping * @param {string} string */ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** * @param {string} str */ function slash(str) { return str.replace(/\\/g, '/') } /** * @param {string} filePath * @param {import('./core.js').Vfs} vfs * @returns {Promise<Exclude<CodeFormat, 'mixed' | 'unknown'>>} */ export async function getFilePathFormat(filePath, vfs) { // React Native bundler treats `.native.js` as special platform extension, the real format // can be found after stripping `.native.js`, which I think is good enough? // https://reactnative.dev/docs/platform-specific-code#native-specific-extensions-ie-sharing-code-with-nodejs-and-web // https://github.com/apollographql/apollo-client/issues/9976#issuecomment-1545472585 // https://github.com/facebook/metro/issues/535 if (filePath.endsWith('.native.js')) filePath = filePath.slice(0, -10) if (filePath.endsWith('.mjs')) return 'ESM' if (filePath.endsWith('.cjs')) return 'CJS' const nearestPkg = await getNearestPkg(filePath, vfs) return nearestPkg?.type === 'module' ? 'ESM' : 'CJS' } /** * @param {string} filePath * @param {import('./core.js').Vfs} vfs * @returns {Promise<Exclude<CodeFormat, 'mixed' | 'unknown'>>} */ export async function getDtsFilePathFormat(filePath, vfs) { if (filePath.endsWith('.d.mts')) return 'ESM' if (filePath.endsWith('.d.cts')) return 'CJS' const nearestPkg = await getNearestPkg(filePath, vfs) return nearestPkg?.type === 'module' ? 'ESM' : 'CJS' } /** * @param {CodeFormat} format */ export function getCodeFormatExtension(format) { switch (format) { case 'ESM': return '.mjs' case 'CJS': return '.cjs' default: return '.js' } } /** * @param {CodeFormat} format */ export function getDtsCodeFormatExtension(format) { switch (format) { case 'ESM': return '.mts' case 'CJS': return '.cts' default: return '.ts' } } /** * @param {string} path */ export function isExplicitExtension(path) { return path.endsWith('.mjs') || path.endsWith('.cjs') } /** * only lint JS files. TS and others not supported. * @param {string} filePath */ export function isFilePathLintable(filePath) { return lintableFileExtensions.some((ext) => filePath.endsWith(ext)) } // support: // // @flow // /* @flow */ // /** @flow */ // /** // * @flow // */ const FLOW_COMMENT_RE = /^\s*(?:\/\/|\/\*\*?|\*)\s*@flow/m /** * don't lint Flow files, which is annotated by an initial `@flow` comment * @param {string} fileContent */ export function isFileContentLintable(fileContent) { return !FLOW_COMMENT_RE.test(fileContent) } /** * Whether the `filePath` looks like a relative path, e.g. * - ./foo * - ../foo * - foo/bar * @param {string} filePath */ export function isRelativePath(filePath) { if (filePath[0] === '.') return true return filePath[0] !== '/' && !filePath.includes(':') } /** * Whether the `filePath` looks like an absoluate path * @param {string} filePath */ export function isAbsolutePath(filePath) { return ( filePath[0] === '/' || (filePath[1] === ':' && filePath[0].match(/[a-zA-Z]/)) ) } /** * @param {string} filePath * @param {import('./core.js').Vfs} vfs * @returns {Promise<Pkg | undefined>} */ export async function getNearestPkg(filePath, vfs) { let currentDir = vfs.getDirName(filePath) while (true) { const pkgJsonPath = vfs.pathJoin(currentDir, 'package.json') if (await vfs.isPathExist(pkgJsonPath)) { try { return JSON.parse(await vfs.readFile(pkgJsonPath)) } catch { // ignore malformed package.json **cough** resolve **cough** } } const nextDir = vfs.getDirName(currentDir) if (nextDir === currentDir) break currentDir = nextDir } } /** * @param {string[]} path * @returns {string} */ export function formatMessagePath(path) { let formatted = 'pkg' for (const part of path) { if (/^\d+$/.test(part)) { // plain number formatted += `[${part}]` } else if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(part)) { // is invalid js var name formatted += `["${part}"]` } else { formatted += '.' + part } } return formatted } /** * @param {Pkg} pkg * @param {string[]} path * @returns {any} */ export function getPkgPathValue(pkg, path) { let v = /** @type {any} */ (pkg) for (const p of path) { v = v[p] } return v } export function createPromiseQueue() { /** @type {Promise<void>[]} */ const promises = [] return { push: (/** @type {Function} */ fn) => promises.push(fn()), wait: () => Promise.all(promises), } } /** * @param {Record<string, any>} pkgJson * @param {string} field * @returns {[value: any, path: string[]]} */ export function getPublishedField(pkgJson, field) { if (pkgJson.publishConfig?.[field]) { return [pkgJson.publishConfig[field], ['publishConfig', field]] } else { return [pkgJson[field], [field]] } } /** * @param {Record<string, any>} obj * @param {string} key */ export function objectHasKeyNested(obj, key) { for (const k in obj) { if (k === key) return true if (typeof obj[k] === 'object' && objectHasKeyNested(obj[k], key)) return true } return false } /** * @param {string} filePath */ export function getAdjacentDtsPath(filePath) { // foo.js -> foo.d.ts // foo.mjs -> foo.d.mts // foo.cjs -> foo.d.cts // foo.jsx -> foo.d.ts return filePath.replace(/\.([mc]?)jsx?$/, '.d.$1ts') } const DTS_RE = /\.d\.[mc]?ts$/ /** * @param {string} filePath */ export function isDtsFile(filePath) { return DTS_RE.test(filePath) } /** * simplified `exports` field resolver that expects `exportsValue` to be the path value directly. * no path matching will happen. `exportsValue` should be an object that contains only conditions * and their values, or a string. * * TODO: look into using https://github.com/lukeed/resolve.exports * @param {Record<string, any> | string | string[]} exportsValue * @param {string[]} conditions * @param {string[]} [currentPath] matched conditions while resolving the exports * @returns {{ value: string, path: string[] } | undefined} */ export function resolveExports(exportsValue, conditions, currentPath = []) { if (typeof exportsValue === 'string') { return { value: exportsValue, path: currentPath } } else if (Array.isArray(exportsValue)) { return resolveExports(exportsValue[0], conditions, currentPath.concat('0')) } for (const key in exportsValue) { if (conditions.includes(key) || key === 'default') { const result = resolveExports( exportsValue[key], conditions, currentPath.concat(key), ) if (result || key === 'default') return result } } } /** * @param {string} str * @param {string} search * @param {string} replace */ export function replaceLast(str, search, replace) { const index = str.lastIndexOf(search) if (index === -1) return str return str.slice(0, index) + replace + str.slice(index + search.length) } /** * @param {string} code */ export function startsWithShebang(code) { return code.startsWith('#!/usr/bin/env') }