npq 3.22.1 → 3.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,11 +150,32 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
150
150
  | expired domains | Normalizes and checks complete maintainer email hosts, then corroborates a simple two-label DNS failure through authoritative RDAP before warning | Multipart and otherwise inconclusive results are reported as not evaluated; see [expired-domain marshall documentation](docs/feature/expired-domains.md)
151
151
  | signatures | Will compare the package's signature as it shows on the registry's packument with signing keys published by the selected registry | Reports `not evaluated` if the selected registry does not expose signing keys
152
152
  | provenance | Will verify the package's attestations of provenance metadata for the published package, and **error** on [provenance regression](docs/feature/provenance.md) (an older semver had registry provenance metadata but the version you install does not) | Reports `not evaluated` if the selected registry does not expose signing keys or attestations
153
- | version-maturity | Will show a warning if the specific version being installed was published less than 7 days ago | Helps identify recently published versions that may not have been reviewed by the community yet
153
+ | version-maturity | Will show an error if the specific version being installed was published less than 7 days ago | Suggests the highest older semver outside npq's 30-day version-recency window when one is available
154
154
  | newBin | Will show a warning if the package version being installed introduces a new command-line binary (via the `bin` field in `package.json`) that was not present in its previous version. | Helps identify potentially unexpected new executables being added to your `node_modules/.bin/` directory.
155
155
  | typosquatting | Will show a warning if the package name is similar to a popular package name, which could indicate a potential typosquatting attack. | Helps identify packages that may be trying to trick users into installing them by mimicking popular package names.
156
156
  | deprecation | Will show a warning if the package version is deprecated on npm or if its GitHub repository has been archived. | Helps identify packages that are no longer maintained or recommended for use. Set `GITHUB_TOKEN` environment variable for higher GitHub API rate limits.
157
157
 
158
+ ### Older release suggestions
159
+
160
+ When a requested version fails the version-maturity check, npq may show the highest older
161
+ semver published outside its 30-day package-version recency window:
162
+
163
+ ```text
164
+ │
165
+ │ ℹ Suggested older release · @openai/codex@0.121.0 — published 33 days ago
166
+ │ Clears npq's package-version recency windows. Re-run npq to audit this
167
+ │ version; compatibility is not evaluated.
168
+ ```
169
+
170
+ The suggestion:
171
+
172
+ - can cross a major-version boundary for tags such as `latest`;
173
+ - is based on cached package publication metadata and is not automatically installed;
174
+ - is not a statement that the older release is safe or that it passes every npq check;
175
+ - does not evaluate API or runtime compatibility.
176
+
177
+ Run npq again with the suggested version and review its compatibility before installing it.
178
+
158
179
  ### Disabling Marshalls
159
180
 
160
181
  To disable a marshall altogether, set an environment variable using with the marshall's shortname.
@@ -160,6 +160,7 @@ function reportResults(marshallResults, { plain = false } = {}) {
160
160
 
161
161
  if (isPackageMalicious) {
162
162
  countErrors = 1
163
+ result.suggestions = []
163
164
 
164
165
  // Rich formatting version
165
166
  const topicRich = styleText(
@@ -242,6 +243,38 @@ function reportResults(marshallResults, { plain = false } = {}) {
242
243
  })
243
244
  }
244
245
 
246
+ if (!isPackageMalicious && result.suggestions && result.suggestions.length > 0) {
247
+ result.suggestions.forEach((suggestion) => {
248
+ const iconRich = styleText(['cyan'], 'ℹ')
249
+ const labelRich = styleText(['cyan', 'bold'], 'Suggested older release')
250
+ const headline = `${suggestion.packageSpec} — published ${suggestion.ageDays} days ago`
251
+ const detail =
252
+ "Clears npq's package-version recency windows. Re-run npq to audit this version; compatibility is not evaluated."
253
+ const suggestionIndent = ` ${styleText(['gray'], '│')} `
254
+ const headlineWidth = Math.max(
255
+ 20,
256
+ terminalWidth - getDisplayLength(' │ ℹ Suggested older release · ') - 2
257
+ )
258
+ const detailWidth = Math.max(20, terminalWidth - getDisplayLength(' │ ') - 2)
259
+ const wrappedHeadline = wrapTextWithIndent(
260
+ headline,
261
+ headlineWidth,
262
+ suggestionIndent
263
+ )
264
+ const wrappedDetail = wrapTextWithIndent(detail, detailWidth, suggestionIndent)
265
+
266
+ resultsForPrettyPrint += `\n ${prefixRich}`
267
+ resultsForPrettyPrint +=
268
+ `\n ${prefixRich} ${iconRich} ${labelRich} ${separatorRich} ${wrappedHeadline}`
269
+ resultsForPrettyPrint += `\n${suggestionIndent}${wrappedDetail}`
270
+
271
+ resultsForPlainTextPrint +=
272
+ `\n SUGGESTION: ${suggestion.packageSpec} was published ${suggestion.ageDays} days ago ` +
273
+ 'and clears the package-version recency windows. Re-run npq to audit it; ' +
274
+ 'compatibility is not evaluated.'
275
+ })
276
+ }
277
+
245
278
  // Close rich formatting box
246
279
  resultsForPrettyPrint += `\n ${styleText(['dim'], '└─')}`
247
280
  })
@@ -297,8 +330,10 @@ function marshallResultsToIssuesPerPackage(results) {
297
330
  pkg,
298
331
  errors: [],
299
332
  warnings: [],
300
- notEvaluated: []
333
+ notEvaluated: [],
334
+ suggestions: []
301
335
  }
336
+ const suggestionKeys = new Set()
302
337
  for (const marshall of allMarshallResultsList) {
303
338
  for (const value of Object.values(marshall)) {
304
339
  if (value.errors && value.errors.length > 0) {
@@ -333,12 +368,24 @@ function marshallResultsToIssuesPerPackage(results) {
333
368
  }))
334
369
  issues.notEvaluated.push(skippedChecks)
335
370
  }
371
+ if (value.suggestions && value.suggestions.length > 0) {
372
+ value.suggestions.forEach((entry) => {
373
+ const suggestion = { ...entry }
374
+ delete suggestion.pkg
375
+ const key = `${suggestion.type}:${suggestion.packageSpec}`
376
+ if (!suggestionKeys.has(key)) {
377
+ suggestionKeys.add(key)
378
+ issues.suggestions.push(suggestion)
379
+ }
380
+ })
381
+ }
336
382
  }
337
383
  }
338
384
  if (
339
385
  issues.errors.length > 0 ||
340
386
  issues.warnings.length > 0 ||
341
- issues.notEvaluated.length > 0
387
+ issues.notEvaluated.length > 0 ||
388
+ issues.suggestions.length > 0
342
389
  ) {
343
390
  issuesPerPackage.push(issues)
344
391
  }
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+
3
+ const semver = require('semver')
4
+
5
+ const DAY_MS = 24 * 60 * 60 * 1000
6
+ const MAX_VERSION_RECENCY_DAYS = 30
7
+
8
+ function getRoundedVersionAgeDays(publishedAt, now = Date.now()) {
9
+ const publishedAtMs = Date.parse(publishedAt)
10
+ const nowMs = now instanceof Date ? now.getTime() : Number(now)
11
+
12
+ if (Number.isNaN(publishedAtMs) || Number.isNaN(nowMs)) {
13
+ return null
14
+ }
15
+
16
+ const ageMs = nowMs - publishedAtMs
17
+ return ageMs > 0 ? Math.round(ageMs / DAY_MS) : 0
18
+ }
19
+
20
+ function findAlternativeVersion({
21
+ packageInfo,
22
+ targetVersion,
23
+ requestedVersion,
24
+ now = Date.now()
25
+ }) {
26
+ if (
27
+ !packageInfo ||
28
+ !packageInfo.versions ||
29
+ !packageInfo.time ||
30
+ !semver.valid(targetVersion)
31
+ ) {
32
+ return null
33
+ }
34
+
35
+ const targetIsStable = semver.prerelease(targetVersion) === null
36
+ const requestedRange =
37
+ typeof requestedVersion === 'string' && !semver.valid(requestedVersion)
38
+ ? semver.validRange(requestedVersion)
39
+ : null
40
+
41
+ const candidates = Object.keys(packageInfo.versions)
42
+ .filter((version) => semver.valid(version) && semver.lt(version, targetVersion))
43
+ .filter((version) => !targetIsStable || semver.prerelease(version) === null)
44
+ .filter((version) => !requestedRange || semver.satisfies(version, requestedRange))
45
+ .map((version) => {
46
+ const publishedAt = packageInfo.time[version]
47
+ const ageDays = getRoundedVersionAgeDays(publishedAt, now)
48
+ return { version, publishedAt, ageDays }
49
+ })
50
+ .filter(
51
+ ({ publishedAt, ageDays }) =>
52
+ typeof publishedAt === 'string' &&
53
+ ageDays !== null &&
54
+ ageDays > MAX_VERSION_RECENCY_DAYS
55
+ )
56
+ .sort((left, right) => semver.rcompare(left.version, right.version))
57
+
58
+ return candidates[0] || null
59
+ }
60
+
61
+ module.exports = {
62
+ MAX_VERSION_RECENCY_DAYS,
63
+ findAlternativeVersion,
64
+ getRoundedVersionAgeDays
65
+ }
@@ -34,6 +34,7 @@
34
34
 
35
35
  const BaseMarshall = require('./baseMarshall')
36
36
  const Warning = require('../helpers/warning')
37
+ const { MAX_VERSION_RECENCY_DAYS } = require('../helpers/versionAge')
37
38
  const { marshallCategories } = require('./constants')
38
39
 
39
40
  const MARSHALL_NAME = 'author'
@@ -202,7 +203,7 @@ class Marshall extends BaseMarshall {
202
203
  )
203
204
  }
204
205
 
205
- if (dateDiffVersionPublished <= 30) {
206
+ if (dateDiffVersionPublished <= MAX_VERSION_RECENCY_DAYS) {
206
207
  throw new Warning(
207
208
  `This version was published only ${dateDiffVersionPublished} days ago by ${npmUser.name} <${npmUser.email}>`
208
209
  )
@@ -22,6 +22,7 @@ class BaseMarshall {
22
22
  status: null,
23
23
  errors: [],
24
24
  warnings: [],
25
+ suggestions: [],
25
26
  notEvaluated: [],
26
27
  data: {},
27
28
  marshall: this.name,
@@ -60,6 +61,13 @@ class BaseMarshall {
60
61
  return undefined
61
62
  }
62
63
 
64
+ if (err.suggestion && typeof err.suggestion === 'object') {
65
+ this.setSuggestion({
66
+ pkg: pkg.packageString,
67
+ ...err.suggestion
68
+ })
69
+ }
70
+
63
71
  this.setMessage(message, Boolean(err instanceof Warning))
64
72
  return undefined
65
73
  })
@@ -89,6 +97,10 @@ class BaseMarshall {
89
97
  })
90
98
  }
91
99
 
100
+ setSuggestion(suggestion) {
101
+ this.ctx.marshalls[this.name].suggestions.push(suggestion)
102
+ }
103
+
92
104
  /**
93
105
  * Resolves a package version string to an actual semver version
94
106
  * Handles dist-tags (latest, beta, etc.) and direct semver versions
@@ -30,10 +30,14 @@ class Marshall extends BaseMarshall {
30
30
 
31
31
  async validate(pkg) {
32
32
  const data = await this.packageRepoUtils.getPackageInfo(pkg.packageName)
33
- const lastVersionData =
34
- data.versions && data['dist-tags'] && data.versions[data['dist-tags'].latest]
33
+ const packageVersion = await this.resolvePackageVersion(
34
+ pkg.packageName,
35
+ pkg.packageVersion || 'latest',
36
+ data
37
+ )
38
+ const versionData = packageVersion && data.versions && data.versions[packageVersion]
35
39
 
36
- const maintainersAccounts = lastVersionData && lastVersionData.maintainers
40
+ const maintainersAccounts = versionData && versionData.maintainers
37
41
 
38
42
  if (!Array.isArray(maintainersAccounts) || maintainersAccounts.length === 0) {
39
43
  throw new NotEvaluated('no maintainers information available for the package')
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const BaseMarshall = require('./baseMarshall')
4
+ const { findAlternativeVersion } = require('../helpers/versionAge')
4
5
  const { marshallCategories } = require('./constants')
5
6
 
6
7
  const MARSHALL_NAME = 'version_maturity'
@@ -35,7 +36,8 @@ class Marshall extends BaseMarshall {
35
36
  }
36
37
 
37
38
  const versionReleaseDate = data.time[packageVersion]
38
- const versionDateDiff = new Date() - new Date(versionReleaseDate)
39
+ const now = Date.now()
40
+ const versionDateDiff = now - new Date(versionReleaseDate)
39
41
  const versionDateDiffInDays = Math.round(versionDateDiff / (1000 * 60 * 60 * 24))
40
42
 
41
43
  if (versionDateDiffInDays < VERSION_AGE_THRESHOLD) {
@@ -50,9 +52,29 @@ class Marshall extends BaseMarshall {
50
52
  timeAgoText = 'day'
51
53
  }
52
54
 
53
- throw new Error(
55
+ const error = new Error(
54
56
  `Detected a recently published version: published ${timeAgoNumber} ${timeAgoText} ago. Consider waiting for community review.`
55
57
  )
58
+ const alternative = findAlternativeVersion({
59
+ packageInfo: data,
60
+ targetVersion: packageVersion,
61
+ requestedVersion: pkg.packageVersion,
62
+ now
63
+ })
64
+
65
+ if (alternative) {
66
+ error.suggestion = {
67
+ type: 'alternative-version',
68
+ packageName: pkg.packageName,
69
+ version: alternative.version,
70
+ packageSpec: `${pkg.packageName}@${alternative.version}`,
71
+ publishedAt: alternative.publishedAt,
72
+ ageDays: alternative.ageDays,
73
+ reason: 'version-recency'
74
+ }
75
+ }
76
+
77
+ throw error
56
78
  }
57
79
 
58
80
  return pkg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npq",
3
- "version": "3.22.1",
3
+ "version": "3.23.1",
4
4
  "description": "marshall your npm/npm package installs with high quality and class 🎖",
5
5
  "bin": {
6
6
  "npq": "./bin/npq.js",