Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-phantom-dep-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@naverpay/pite": minor
---

feat: phantom dependency check and core-js-pure version enforcement
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
39 changes: 25 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -98,6 +110,7 @@ export function createViteConfig({
cssFileName = 'style.css',
visualize = false,
publint: {severity = 'error'} = {},
phantomDepCheck: {severity: phantomDepSeverity = 'error'} = {},
includeRequiredPolyfill = [],
skipRequiredPolyfillCheck = [],
vitePlugins = [],
Expand Down Expand Up @@ -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),
Expand All @@ -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,
},
Expand Down
124 changes: 124 additions & 0 deletions src/phantom-deps.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
peerDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
devDependencies?: Record<string, string>
bundledDependencies?: string[]
}

export interface PhantomCheckResult {
phantoms: Map<string, {files: Set<string>; required: string; misplacedIn?: string}>
outdated: Map<string, {declared: string; floor: string; required: string}>
unverifiable: Map<string, {declared: string; required: string}>
skipReason?: string
}

const matchesPackage = (specifier: string, name: string) => specifier === name || specifier.startsWith(`${name}/`)

const collectImporters = (outputImports: Map<string, Set<string>>, name: string): Set<string> => {
const files = new Set<string>()
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<string, Set<string>>): 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}
}
Loading
Loading