Skip to content

Commit 5d44351

Browse files
feat: show warning when engines mismatch (#51)
* feat: check mismatch engine * refactor: use `Engines` * refactor: unify `formatPackageId` * [autofix.ci] apply automated fixes * test: fix type * update * docs: update --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent f5716f0 commit 5d44351

12 files changed

Lines changed: 220 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
| `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` |
4747
| `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` |
4848
| `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` |
49+
| `npmx.diagnostics.engineMismatch` | Show warnings when dependency engines mismatch with the current package | `boolean` | `true` |
4950

5051
<!-- configs -->
5152

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@
9393
"type": "boolean",
9494
"default": true,
9595
"description": "Show warnings when a dependency uses a dist tag"
96+
},
97+
"npmx.diagnostics.engineMismatch": {
98+
"type": "boolean",
99+
"default": true,
100+
"description": "Show warnings when dependency engines mismatch with the current package"
96101
}
97102
}
98103
},

playground/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
2+
"engines": {
3+
"node": ">16"
4+
},
25
"dependencies": {
36
"@deno/doc": "jsr:^0.189.1",
47
"@prismicio/client": "~7.21.0-canary.147e3f2",

src/extractors/package-json.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DependencyInfo, Extractor } from '#types/extractor'
2+
import type { Engines } from 'fast-npm-meta'
23
import type { Node } from 'jsonc-parser'
34
import type { TextDocument } from 'vscode'
45
import { isInRange } from '#utils/ast'
@@ -75,6 +76,25 @@ export class PackageJsonExtractor implements Extractor<Node> {
7576
return result
7677
}
7778

79+
getEngines(root: Node): Engines | undefined {
80+
const enginesNode = findNodeAtLocation(root, ['engines'])
81+
if (enginesNode?.type !== 'object' || !enginesNode.children?.length)
82+
return
83+
84+
let engines: Engines | undefined
85+
86+
for (const engineNode of enginesNode.children) {
87+
const [nameNode, rangeNode] = engineNode.children ?? []
88+
if (typeof nameNode?.value !== 'string' || typeof rangeNode?.value !== 'string')
89+
continue
90+
91+
engines ??= {}
92+
engines[nameNode.value] = rangeNode.value
93+
}
94+
95+
return engines
96+
}
97+
7898
getDependencyInfoByOffset(root: Node, offset: number) {
7999
const node = findNodeAtOffset(root, offset)
80100
if (!node || node.type !== 'string' || !this.isInDependencySection(root, node))

src/providers/diagnostics/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DependencyInfo, ValidNode } from '#types/extractor'
22
import type { PackageInfo } from '#utils/api/package'
33
import type { ParsedVersion } from '#utils/version'
4+
import type { Engines } from 'fast-npm-meta'
45
import type { Awaitable } from 'reactive-vscode'
56
import type { Diagnostic, TextDocument } from 'vscode'
67
import { useActiveExtractor } from '#composables/active-extractor'
@@ -14,6 +15,7 @@ import { languages } from 'vscode'
1415
import { displayName } from '../../generated-meta'
1516
import { checkDeprecation } from './rules/deprecation'
1617
import { checkDistTag } from './rules/dist-tag'
18+
import { checkEngineMismatch } from './rules/engine-mismatch'
1719
import { checkReplacement } from './rules/replacement'
1820
import { checkUpgrade } from './rules/upgrade'
1921
import { checkVulnerability } from './rules/vulnerability'
@@ -23,6 +25,7 @@ export interface DiagnosticContext {
2325
pkg: PackageInfo
2426
parsed: ParsedVersion | null
2527
exactVersion: string | null
28+
engines: Engines | undefined
2629
}
2730

2831
export interface NodeDiagnosticInfo extends Omit<Diagnostic, 'range' | 'source'> {
@@ -45,6 +48,8 @@ export function useDiagnostics() {
4548
rules.push(checkDeprecation)
4649
if (config.diagnostics.distTag)
4750
rules.push(checkDistTag)
51+
if (config.diagnostics.engineMismatch)
52+
rules.push(checkEngineMismatch)
4853
if (config.diagnostics.replacement)
4954
rules.push(checkReplacement)
5055
if (config.diagnostics.vulnerability)
@@ -83,6 +88,7 @@ export function useDiagnostics() {
8388
const targetVersion = document.version
8489

8590
const dependencies = extractor.getDependenciesInfo(root)
91+
const engines = extractor.getEngines?.(root)
8692
const diagnostics: Diagnostic[] = []
8793

8894
for (const dep of dependencies) {
@@ -103,7 +109,7 @@ export function useDiagnostics() {
103109

104110
for (const rule of rules) {
105111
try {
106-
const diagnostic = await rule({ dep, pkg, parsed, exactVersion })
112+
const diagnostic = await rule({ dep, pkg, parsed, exactVersion, engines })
107113
if (isDocumentChanged(document, targetUri, targetVersion))
108114
return
109115
if (!diagnostic)

src/providers/diagnostics/rules/deprecation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DiagnosticRule } from '..'
22
import { npmxPackageUrl } from '#utils/links'
3+
import { formatPackageId } from '#utils/package'
34
import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode'
45

56
export const checkDeprecation: DiagnosticRule = ({ dep, pkg, parsed, exactVersion }) => {
@@ -13,7 +14,7 @@ export const checkDeprecation: DiagnosticRule = ({ dep, pkg, parsed, exactVersio
1314

1415
return {
1516
node: dep.versionNode,
16-
message: `${dep.name} v${exactVersion} has been deprecated: ${versionInfo.deprecated}`,
17+
message: `"${formatPackageId(dep.name, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`,
1718
severity: DiagnosticSeverity.Error,
1819
code: {
1920
value: 'deprecation',
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { Engines } from 'fast-npm-meta'
2+
import type { DiagnosticRule } from '..'
3+
import { npmxPackageUrl } from '#utils/links'
4+
import { formatPackageId } from '#utils/package'
5+
import Range from 'semver/classes/range'
6+
import intersects from 'semver/ranges/intersects'
7+
import subset from 'semver/ranges/subset'
8+
import { DiagnosticSeverity, Uri } from 'vscode'
9+
10+
interface EngineMismatch {
11+
engine: string
12+
packageRange: string
13+
dependencyRange: string
14+
hasIntersection: boolean
15+
}
16+
17+
function resolveEngineMismatches(
18+
packageEngines: Engines,
19+
dependencyEngines: Engines,
20+
) {
21+
const mismatches: EngineMismatch[] = []
22+
23+
for (const [engine, dependencyRangeStr] of Object.entries(dependencyEngines)) {
24+
const packageRangeStr = packageEngines[engine]
25+
if (!packageRangeStr || !dependencyRangeStr)
26+
continue
27+
28+
try {
29+
const pkgRange = new Range(packageRangeStr)
30+
const depRange = new Range(dependencyRangeStr)
31+
32+
if (subset(pkgRange, depRange))
33+
continue
34+
35+
mismatches.push({
36+
engine,
37+
packageRange: packageRangeStr,
38+
dependencyRange: dependencyRangeStr,
39+
hasIntersection: intersects(pkgRange, depRange),
40+
})
41+
} catch {
42+
continue
43+
}
44+
}
45+
46+
return mismatches
47+
}
48+
49+
export const checkEngineMismatch: DiagnosticRule = ({ dep, pkg, parsed, exactVersion, engines }) => {
50+
if (!parsed || !exactVersion || !engines)
51+
return
52+
53+
const dependencyEngines = pkg.versionsMeta[exactVersion]?.engines
54+
if (!dependencyEngines)
55+
return
56+
57+
const mismatches = resolveEngineMismatches(engines, dependencyEngines)
58+
if (mismatches.length === 0)
59+
return
60+
61+
const mismatchDetails = mismatches
62+
.map((mismatch) => `${mismatch.engine}: requires "${mismatch.dependencyRange}", but package supports "${mismatch.packageRange}"${mismatch.hasIntersection ? ' (partial overlap)' : ''}`)
63+
.join('; ')
64+
65+
return {
66+
node: dep.versionNode,
67+
message: `Engines mismatch for "${formatPackageId(dep.name, exactVersion)}": ${mismatchDetails}.`,
68+
severity: DiagnosticSeverity.Warning,
69+
code: {
70+
value: 'engine-mismatch',
71+
target: Uri.parse(npmxPackageUrl(dep.name, parsed.version)),
72+
},
73+
}
74+
}

src/types/extractor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Engines } from 'fast-npm-meta'
12
import type { Node as JsonNode } from 'jsonc-parser'
23
import type { Range, TextDocument } from 'vscode'
34
import type { Node as YamlNode } from 'yaml'
@@ -19,4 +20,6 @@ export interface Extractor<T extends ValidNode = any> {
1920
getDependenciesInfo: (root: T) => DependencyInfo<T>[]
2021

2122
getDependencyInfoByOffset: (root: T, offset: number) => DependencyInfo<T> | undefined
23+
24+
getEngines?: (root: T) => Engines | undefined
2225
}

src/utils/package.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export function encodePackageName(name: string): string {
1212
return encodeURIComponent(name)
1313
}
1414

15+
export function formatPackageId(name: string, version: string): string {
16+
return `${name}@${version}`
17+
}
18+
1519
export function resolveExactVersion(pkg: PackageInfo, version: string) {
1620
if (Object.hasOwn(pkg.distTags, version))
1721
return pkg.distTags[version]

tests/diagnostics/context.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DependencyInfo } from '#types/extractor'
22
import type { PackageInfo } from '#utils/api/package'
3+
import type { Engines } from 'fast-npm-meta'
34
import type { DiagnosticContext } from '../../src/providers/diagnostics'
45
import { resolveExactVersion } from '#utils/package'
56
import { isSupportedProtocol, parseVersion } from '#utils/version'
@@ -8,16 +9,20 @@ interface CreateContextOptions {
89
name: string
910
version: string
1011
distTags?: Record<string, string>
11-
versionsMeta?: Record<string, { deprecated?: string }>
12+
versionsMeta?: Record<string, {
13+
deprecated?: string
14+
engines?: Engines
15+
}>
16+
engines?: Engines
1217
}
1318

1419
export function createContext(options: CreateContextOptions): DiagnosticContext {
15-
const { name, version, distTags = {}, versionsMeta = {} } = options
20+
const { name, version, distTags = {}, versionsMeta = {}, engines } = options
1621
const dep: DependencyInfo = { name, version, nameNode: {}, versionNode: {} }
1722
const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo
1823
const parsed = parseVersion(version)
1924
const exactVersion = parsed && isSupportedProtocol(parsed.protocol)
2025
? resolveExactVersion(pkg, parsed.version)
2126
: null
22-
return { dep, pkg, parsed, exactVersion }
27+
return { dep, pkg, parsed, exactVersion, engines }
2328
}

0 commit comments

Comments
 (0)