diff --git a/.changeset/feat-phantom-dep-check.md b/.changeset/feat-phantom-dep-check.md new file mode 100644 index 0000000..4adc1f9 --- /dev/null +++ b/.changeset/feat-phantom-dep-check.md @@ -0,0 +1,5 @@ +--- +"@naverpay/pite": minor +--- + +feat: phantom dependency check and core-js-pure version enforcement diff --git a/package.json b/package.json index cb0d9ba..7a1ce95 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "rollup-plugin-visualizer": "^5.14.0", "builtin-modules": "^5.0.0", "builtins": "^5.1.0", - "sass-embedded": "^1.83.4" + "sass-embedded": "^1.83.4", + "semver": "^7.8.1" }, "peerDependencies": { "tsup": ">=8.3.5", @@ -79,6 +80,7 @@ "@naverpay/markdown-lint": "^0.0.3", "@naverpay/prettier-config": "^1.0.0", "@types/node": "^22.10.2", + "@types/semver": "^7.7.1", "eslint": "^8.57.0", "lefthook": "^1.8.2", "lint-staged": "^15.2.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d72ad1..cea742a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: sass-embedded: specifier: ^1.83.4 version: 1.83.4 + semver: + specifier: ^7.8.1 + version: 7.8.1 tsup: specifier: '>=8.3.5' version: 8.3.5(@microsoft/api-extractor@7.48.0(@types/node@22.10.2))(postcss@8.5.1)(typescript@5.6.3)(yaml@2.5.1) @@ -78,6 +81,9 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.10.2 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 eslint: specifier: ^8.57.0 version: 8.57.1 @@ -765,6 +771,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@typescript-eslint/eslint-plugin@8.14.0': resolution: {integrity: sha512-tqp8H7UWFaZj0yNO6bycd5YjMwxa6wIHOLZvWPkidwbgLCsBMetQoGj7DPuAlWa2yGO3H48xmPwjhsSPPCGU5w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2783,8 +2792,8 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} engines: {node: '>=10'} hasBin: true @@ -3476,7 +3485,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.6.3 + semver: 7.8.1 '@changesets/assemble-release-plan@6.0.4': dependencies: @@ -3485,7 +3494,7 @@ snapshots: '@changesets/should-skip-package': 0.1.1 '@changesets/types': 6.0.0 '@manypkg/get-packages': 1.1.3 - semver: 7.6.3 + semver: 7.8.1 '@changesets/changelog-git@0.2.0': dependencies: @@ -3518,7 +3527,7 @@ snapshots: package-manager-detector: 0.2.2 picocolors: 1.1.1 resolve-from: 5.0.0 - semver: 7.6.3 + semver: 7.8.1 spawndamnit: 2.0.0 term-size: 2.2.1 @@ -3541,7 +3550,7 @@ snapshots: '@changesets/types': 6.0.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.6.3 + semver: 7.8.1 '@changesets/get-release-plan@4.0.4': dependencies: @@ -4023,6 +4032,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/semver@7.7.1': {} + '@typescript-eslint/eslint-plugin@8.14.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -4083,7 +4094,7 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.8.1 ts-api-utils: 1.4.0(typescript@5.6.3) optionalDependencies: typescript: 5.6.3 @@ -4098,7 +4109,7 @@ snapshots: fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.8.1 ts-api-utils: 1.4.0(typescript@5.6.3) optionalDependencies: typescript: 5.6.3 @@ -4345,7 +4356,7 @@ snapshots: builtins@5.1.0: dependencies: - semver: 7.6.3 + semver: 7.8.1 bundle-require@5.1.0(esbuild@0.24.2): dependencies: @@ -4721,7 +4732,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@8.57.1): dependencies: eslint: 8.57.1 - semver: 7.6.3 + semver: 7.8.1 eslint-config-eslint@11.0.0(eslint@8.57.1): dependencies: @@ -4839,7 +4850,7 @@ snapshots: espree: 10.3.0 esquery: 1.6.0 parse-imports: 2.2.1 - semver: 7.6.3 + semver: 7.8.1 spdx-expression-parse: 4.0.0 synckit: 0.9.2 transitivePeerDependencies: @@ -4874,7 +4885,7 @@ snapshots: globals: 15.12.0 ignore: 5.3.2 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.8.1 eslint-plugin-node@11.1.0(eslint@8.57.1): dependencies: @@ -4933,7 +4944,7 @@ snapshots: read-pkg-up: 7.0.1 regexp-tree: 0.1.27 regjsparser: 0.10.0 - semver: 7.6.3 + semver: 7.8.1 strip-indent: 3.0.0 transitivePeerDependencies: - supports-color @@ -6202,7 +6213,7 @@ snapshots: lru-cache: 6.0.0 optional: true - semver@7.6.3: {} + semver@7.8.1: {} set-function-length@1.2.2: dependencies: diff --git a/src/index.ts b/src/index.ts index ba04779..c3d2078 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import {BuildOptions, defineConfig, Plugin, UserConfig} from 'vite' import {getBrowserslistConfig} from './browserslist' import {getExternalDependencies} from './dependencies' import {getViteEntry} from './get-vite-entry' +import {PITE_INJECTED} from './phantom-deps' +import phantomDepsPlugin from './plugins/rollup-plugin-phantom-deps' import publintPlugin from './plugins/rollup-plugin-publint' import {shouldInjectPolyfill} from './polyfill' import {isValidBrowserslistConfig, replaceExtension} from './util' @@ -54,6 +56,16 @@ export interface ViteConfigProps { * @default - {severity: 'error'} */ publint?: {severity?: 'error' | 'warn' | 'off'} + /** + * Phantom dependency check setting + * + * - `'error'`: Exit code is 1 when phantom dependencies are detected + * - `'warn'`: Prints a warning if phantom dependencies are detected (doesn’t affect exit code) + * - `'off'`: Disables the phantom dependency check + * + * @default - {severity: 'error'} + */ + phantomDepCheck?: {severity?: 'error' | 'warn' | 'off'} /** * List of polyfills that need to be injected */ @@ -98,6 +110,7 @@ export function createViteConfig({ cssFileName = 'style.css', visualize = false, publint: {severity = 'error'} = {}, + phantomDepCheck: {severity: phantomDepSeverity = 'error'} = {}, includeRequiredPolyfill = [], skipRequiredPolyfillCheck = [], vitePlugins = [], @@ -183,7 +196,7 @@ export function createViteConfig({ 'babel-plugin-polyfill-corejs3', { method: 'usage-pure', - version: '3.39.0', + version: PITE_INJECTED['core-js-pure'], proposals: true, shouldInjectPolyfill: shouldInjectPolyfill({ include: new Set(includeRequiredPolyfill), @@ -201,6 +214,7 @@ export function createViteConfig({ ...(visualize ? [visualizer(typeof visualize === 'object' ? visualize : {})] : []), preserveDirectives(), ...(severity !== 'off' ? [publintPlugin({cwd, severity})] : []), + ...(phantomDepSeverity !== 'off' ? [phantomDepsPlugin({cwd, severity: phantomDepSeverity})] : []), ], ...inputRollupOptions, }, diff --git a/src/phantom-deps.ts b/src/phantom-deps.ts new file mode 100644 index 0000000..d445535 --- /dev/null +++ b/src/phantom-deps.ts @@ -0,0 +1,124 @@ +import fs from 'fs' +import path from 'path' + +import semver from 'semver' + +export const PITE_INJECTED = { + 'core-js-pure': '3.39.0', +} as const + +interface Manifest { + name?: unknown + dependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record + devDependencies?: Record + bundledDependencies?: string[] +} + +export interface PhantomCheckResult { + phantoms: Map; required: string; misplacedIn?: string}> + outdated: Map + unverifiable: Map + skipReason?: string +} + +const matchesPackage = (specifier: string, name: string) => specifier === name || specifier.startsWith(`${name}/`) + +const collectImporters = (outputImports: Map>, name: string): Set => { + const files = new Set() + for (const [specifier, importers] of outputImports) { + if (matchesPackage(specifier, name)) { + for (const file of importers) { + files.add(file) + } + } + } + return files +} + +const toSemverRange = (raw: string): string | null => { + let range = raw.trim() + + const alias = /^npm:.+@([^@]+)$/.exec(range) + if (alias) { + range = alias[1] + } + + const gitSemver = /#semver:(.+)$/.exec(range) + if (gitSemver) { + range = gitSemver[1] + } + + if (range.startsWith('workspace:')) { + const rest = range.slice('workspace:'.length) + if (rest === '' || rest === '*' || rest === '^' || rest === '~') { + return null + } + range = rest + } + + const valid = semver.validRange(range) + return valid === null || valid === '*' ? null : range +} + +const findMisplaced = (pkg: Manifest, name: string): string | undefined => { + for (const field of ['peerDependencies', 'optionalDependencies', 'devDependencies'] as const) { + const deps = pkg[field] + if (deps && typeof deps === 'object' && name in deps) { + return field + } + } + return undefined +} + +export const checkPhantomDeps = (cwd: string, outputImports: Map>): PhantomCheckResult => { + const phantoms: PhantomCheckResult['phantoms'] = new Map() + const outdated: PhantomCheckResult['outdated'] = new Map() + const unverifiable: PhantomCheckResult['unverifiable'] = new Map() + const empty = {phantoms, outdated, unverifiable} + + const pkgPath = path.join(cwd, 'package.json') + if (!fs.existsSync(pkgPath)) { + return {...empty, skipReason: `no package.json at ${cwd}`} + } + + let pkg: Manifest + try { + pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + } catch (error) { + return { + ...empty, + skipReason: `could not parse ${pkgPath}: ${error instanceof Error ? error.message : String(error)}`, + } + } + if (typeof pkg.name !== 'string') { + return {...empty, skipReason: `package.json has no "name" field (cwd: ${cwd})`} + } + + for (const [name, required] of Object.entries(PITE_INJECTED)) { + const files = collectImporters(outputImports, name) + if (files.size === 0 || pkg.bundledDependencies?.includes(name)) { + continue + } + + const declared = pkg.dependencies?.[name] + if (declared === undefined) { + phantoms.set(name, {files, required, misplacedIn: findMisplaced(pkg, name)}) + continue + } + + const range = toSemverRange(declared) + if (range === null) { + unverifiable.set(name, {declared, required}) + continue + } + + const floor = semver.minVersion(range)?.version + if (floor && semver.lt(floor, required)) { + outdated.set(name, {declared, floor, required}) + } + } + + return {phantoms, outdated, unverifiable} +} diff --git a/src/plugins/rollup-plugin-phantom-deps.ts b/src/plugins/rollup-plugin-phantom-deps.ts new file mode 100644 index 0000000..be3f818 --- /dev/null +++ b/src/plugins/rollup-plugin-phantom-deps.ts @@ -0,0 +1,139 @@ +/* eslint-disable no-console */ + +import path from 'path' + +import chalk from 'chalk' +import {Plugin} from 'vite' + +import {checkPhantomDeps, PhantomCheckResult} from '../phantom-deps' + +interface PhantomDepsOption { + cwd: string + severity: 'error' | 'warn' +} + +const usedIn = (count: number) => `(used in ${count} file${count > 1 ? 's' : ''})` + +const sampleFiles = (files: Set): string[] => { + const lines = [...files].slice(0, 3).map((file) => chalk.dim(` ${file}`)) + if (files.size > 3) { + lines.push(chalk.dim(` ... and ${files.size - 3} more`)) + } + return lines +} + +const fixDependencies = (entries: [string, {required: string; misplacedIn?: string}][]): string[] => { + const allMisplaced = entries.every(([, p]) => p.misplacedIn) + const verb = allMisplaced ? 'move to' : 'add to' + return [ + chalk.yellow(`Fix: ${verb} "dependencies" in package.json:`), + ...entries.map(([name, {required}]) => ` "${name}": ">=${required}"`), + ] +} + +const formatPhantoms = (phantoms: PhantomCheckResult['phantoms'], mark: string): string => { + const lines = ['Injected into the build output but not declared in package.json:', ''] + for (const [name, {files, misplacedIn}] of phantoms) { + lines.push(` ${mark} ${chalk.bold(name)} ${usedIn(files.size)}`, ...sampleFiles(files)) + if (misplacedIn) { + lines.push(chalk.yellow(` declared in "${misplacedIn}". move it to "dependencies"`)) + } + } + lines.push( + '', + chalk.yellow('Why:'), + ' pite injects these as unconditional runtime imports, so the consumer must be able', + ' to resolve them. "peerDependencies"/"optionalDependencies" can be absent at install', + ' and crash at runtime; only "dependencies"/"bundledDependencies" guarantee resolution.', + '', + ...fixDependencies([...phantoms]), + ) + return lines.join('\n') +} + +const formatOutdated = (outdated: PhantomCheckResult['outdated'], mark: string): string => { + const lines = ['Declared below the version pite injects:', ''] + for (const [name, {declared, floor, required}] of outdated) { + lines.push(` ${mark} ${chalk.bold(name)}: declared "${declared}" (floor ${floor}), pite injects >=${required}`) + } + lines.push( + '', + chalk.yellow('Fix: raise the range in package.json:'), + ...[...outdated].map(([name, {required}]) => ` "${name}": ">=${required}"`), + ) + return lines.join('\n') +} + +const formatUnverifiable = (unverifiable: PhantomCheckResult['unverifiable']): string => { + const lines = [chalk.yellow('Declared with a range whose floor pite cannot verify at build time:'), ''] + for (const [name, {declared, required}] of unverifiable) { + lines.push(` ${chalk.yellow('?')} ${chalk.bold(name)}: "${declared}" (pite injects >=${required})`) + } + lines.push( + '', + chalk.dim(' workspace:/catalog:/git/tarball/tag ranges resolve at install time.'), + chalk.dim(' Make sure the resolved version is >= the version above.'), + ) + return lines.join('\n') +} + +const phantomDeps = ({cwd, severity}: PhantomDepsOption): Plugin => { + const outputImports = new Map>() + + const addImport = (specifier: string, file: string) => { + if (!outputImports.has(specifier)) { + outputImports.set(specifier, new Set()) + } + outputImports.get(specifier)!.add(file) + } + + return { + name: 'rollup-plugin-phantom-deps', + buildStart() { + outputImports.clear() + }, + generateBundle(options, bundle) { + for (const [fileName, chunk] of Object.entries(bundle)) { + if (chunk.type !== 'chunk') { + continue + } + const displayPath = options.dir ? path.relative(cwd, path.join(options.dir, fileName)) : fileName + for (const specifier of [...chunk.imports, ...chunk.dynamicImports]) { + addImport(specifier, displayPath) + } + } + }, + closeBundle() { + console.log(chalk.blue('\n[🔨 phantom-deps]')) + const {phantoms, outdated, unverifiable, skipReason} = checkPhantomDeps(cwd, outputImports) + + if (skipReason) { + console.log(chalk.yellow(`Skipped: ${skipReason}\n`)) + return + } + if (phantoms.size === 0 && outdated.size === 0 && unverifiable.size === 0) { + console.log(chalk.green('All good!\n')) + return + } + + const mark = severity === 'error' ? chalk.red('✗') : chalk.yellow('!') + const blocks: string[] = [] + if (phantoms.size > 0) { + blocks.push(formatPhantoms(phantoms, mark)) + } + if (outdated.size > 0) { + blocks.push(formatOutdated(outdated, mark)) + } + if (unverifiable.size > 0) { + blocks.push(formatUnverifiable(unverifiable)) + } + console.log(blocks.join('\n\n') + '\n') + + if (severity === 'error' && (phantoms.size > 0 || outdated.size > 0)) { + this.error('phantom-deps check failed') + } + }, + } +} + +export default phantomDeps diff --git a/vite.config.mjs b/vite.config.mjs index 6ef3d9e..b362612 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -20,7 +20,7 @@ export default createViteConfig({ {format: 'es', dir: 'dist/esm'}, {format: 'cjs', dir: 'dist/cjs'}, ], - skipRequiredPolyfillCheck: ['esnext.json.parse'], + skipRequiredPolyfillCheck: ['esnext.json.parse', 'es.array.push'], options: { rollupOptions: { external: [...deps, ...builtins()],