🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@npmcli/arborist

Package Overview
Dependencies
Maintainers
4
Versions
231
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@npmcli/arborist - npm Package Compare versions

Comparing version
10.0.0-pre.1
to
10.0.0-pre.2
+237
lib/npm-extension.js
// Root-owned `.npm-extension.{mjs,cjs}`: an imperative `transformManifest(pkg, context)` extension point that repairs third-party manifests before Arborist reads a candidate's dependency edges.
// See RFC: https://github.com/npm/rfcs/pull/903
// This module discovers and hashes the root extension file, loads its `transformManifest` export, and applies it to a deeply isolated manifest copy, returning an extended manifest plus minimal provenance.
// It never mutates the input manifest or any shared cache object.
const { resolve, sep } = require('node:path')
const { readFileSync, existsSync } = require('node:fs')
const { pathToFileURL } = require('node:url')
const { isDeepStrictEqual } = require('node:util')
const { log } = require('proc-log')
const ssri = require('ssri')
const { EXTENSION_FIELDS } = require('./package-extensions.js')
const EXTENSION_POINT = 'transformManifest'
// The two supported module formats and their default discovery filenames.
const FORMATS = [
{ ext: 'mjs', file: '.npm-extension.mjs' },
{ ext: 'cjs', file: '.npm-extension.cjs' },
]
const err = (message, code, extra = {}) =>
Object.assign(new Error(message), { code, ...extra })
// Read a file's bytes, or null when it does not exist.
const readBytes = path => {
try {
return readFileSync(path)
} catch (e) {
if (e.code === 'ENOENT') {
return null
}
throw e
}
}
// Resolve which extension file to load, returning { path, format, bytes } or null when none is present.
// A configured `extension-file` wins over default discovery; it must resolve inside the project root and use a `.mjs` or `.cjs` extension.
// When both default files exist npm fails rather than choosing one implicitly.
const discover = (root, extensionFile) => {
if (extensionFile) {
const path = resolve(root, extensionFile)
if (path !== root && !path.startsWith(root + sep)) {
throw err(`extension-file "${extensionFile}" must resolve inside the project root`, 'ENPMEXTENSIONPATH')
}
const format = FORMATS.find(f => path.endsWith(`.${f.ext}`))?.ext
if (!format) {
throw err(`extension-file "${extensionFile}" must use a .mjs or .cjs extension`, 'ENPMEXTENSIONPATH')
}
const bytes = readBytes(path)
if (bytes === null) {
throw err(`extension-file "${extensionFile}" was not found`, 'ENPMEXTENSIONPATH')
}
return { path, format, bytes }
}
const found = FORMATS
.map(f => ({ path: resolve(root, f.file), format: f.ext, bytes: readBytes(resolve(root, f.file)) }))
.filter(f => f.bytes !== null)
if (found.length > 1) {
throw err('found both .npm-extension.mjs and .npm-extension.cjs; keep only one', 'ENPMEXTENSIONDUP')
}
return found[0] || null
}
// Hash the selected extension file: a format-tagged prefix plus the raw file bytes, using npm's lockfile digest encoding.
// The prefix keeps `.mjs` and `.cjs` files with identical bytes distinct, and excludes any path so the digest is machine-independent.
const hashFile = (format, bytes) =>
ssri.fromData(
Buffer.concat([Buffer.from(`npm-extension:v1:${format}\n`), bytes]),
{ algorithms: ['sha512'] }
).toString()
class NpmExtension {
#cache = new Map()
#transform = null
constructor ({ root, extensionFile = null } = {}) {
this.root = root
this.path = null
this.format = null
this.hash = null
const selected = root ? discover(root, extensionFile) : null
if (!selected) {
this.present = false
return
}
this.present = true
this.path = selected.path
this.format = selected.format
this.hash = hashFile(selected.format, selected.bytes)
}
// Import the extension module and capture its `transformManifest` export.
// ESM files are loaded with dynamic import; CommonJS files with require.
// The export must be a function; anything else fails the install.
async load () {
if (!this.present) {
return
}
// Key the module load by the file hash so a changed file is reloaded rather than served stale from a module cache within one process.
let mod
if (this.format === 'mjs') {
mod = await import(`${pathToFileURL(this.path).href}?h=${this.hash}`)
} else {
delete require.cache[require.resolve(this.path)]
mod = require(this.path)
}
const transform = mod?.transformManifest ?? mod?.default?.transformManifest
if (typeof transform !== 'function') {
throw err(`.npm-extension must export a transformManifest function`, 'ENPMEXTENSIONSHAPE')
}
this.#transform = transform
}
// Apply transformManifest to a candidate manifest, returning { pkg, applied } or null when nothing changed.
// Results are cached once per resolved package identity; consumers get a deeply isolated copy so they cannot mutate the cached effective manifest.
// Pass { memoize: false } to run without caching, e.g. a staleness probe over partial lockfile manifests that must not seed the cache used by full-manifest fetches.
apply (pkg, { memoize = true } = {}) {
if (!this.#transform || !pkg?.name) {
return null
}
const key = this.#identity(pkg)
let result
if (this.#cache.has(key)) {
result = this.#cache.get(key)
} else {
result = this.#run(pkg)
if (memoize) {
this.#cache.set(key, result)
}
}
return result && { pkg: structuredClone(result.pkg), applied: structuredClone(result.applied) }
}
// Identity key for the transform cache: package integrity when available, otherwise resolved source plus name and version.
#identity (pkg) {
return pkg._integrity || `${pkg._resolved || ''}:${pkg.name}@${pkg.version || ''}`
}
// Run transformManifest on a deeply isolated copy and validate the result.
// Returns { pkg, applied } when dependency or peer metadata changed, or null when it did not.
#run (pkg) {
const context = {
log: message => log.silly('npm-extension', message),
root: this.root,
extensionPoint: EXTENSION_POINT,
}
let returned
try {
returned = this.#transform(structuredClone(pkg), context)
} catch (e) {
throw err(
`.npm-extension transformManifest threw while processing ${pkg.name}@${pkg.version}: ${e.message}`,
'ENPMEXTENSIONTHROW', { pkgid: `${pkg.name}@${pkg.version}` })
}
if (returned && typeof returned.then === 'function') {
throw err(
`.npm-extension transformManifest must return a manifest synchronously, not a promise, for ${pkg.name}@${pkg.version}`,
'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` })
}
if (!returned || typeof returned !== 'object' || Array.isArray(returned)) {
throw err(
`.npm-extension transformManifest must return a manifest object for ${pkg.name}@${pkg.version}`,
'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` })
}
// Only dependency and peer fields may change; any other field the returned manifest explicitly alters is rejected.
// Fields the returned object omits are left untouched, so a handler may return a new object with only the fields it repairs.
for (const k of Object.keys(returned)) {
if (!EXTENSION_FIELDS.includes(k) && !isDeepStrictEqual(returned[k], pkg[k])) {
throw err(
`.npm-extension transformManifest changed unsupported field "${k}" on ${pkg.name}@${pkg.version}; only ${EXTENSION_FIELDS.join(', ')} may change`,
'ENPMEXTENSIONFIELD', { pkgid: `${pkg.name}@${pkg.version}`, field: k })
}
}
// Build the effective manifest from the normalized baseline plus the returned allowlisted fields.
// A field the handler omits is left as the baseline; delete individual entries by returning a field object without them.
const next = { ...pkg }
for (const field of EXTENSION_FIELDS) {
if (returned[field] === undefined) {
continue
}
this.#validateField(field, returned[field], pkg)
next[field] = returned[field]
}
const applied = this.#provenance(pkg, next)
return applied && { pkg: next, applied }
}
// Validate a returned allowlisted field and its entries, so invalid output fails with .npm-extension and package context.
// Dependency maps hold version strings; peerDependenciesMeta holds metadata objects.
#validateField (field, value, pkg) {
const fail = (suffix) => err(
`.npm-extension transformManifest set ${suffix} to an invalid value on ${pkg.name}@${pkg.version}`,
'ENPMEXTENSIONVALUE', { pkgid: `${pkg.name}@${pkg.version}`, field })
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw fail(field)
}
for (const [name, entry] of Object.entries(value)) {
if (field === 'peerDependenciesMeta') {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
throw fail(`${field}.${name}`)
}
} else if (typeof entry !== 'string') {
throw fail(`${field}.${name}`)
}
}
}
// Minimal provenance: the extension point plus, for each changed allowlisted field, a sorted array of affected dependency names.
// Returns null when nothing changed.
#provenance (before, after) {
const applied = { extensionPoint: EXTENSION_POINT }
let changed = false
for (const field of EXTENSION_FIELDS) {
const b = before[field] || {}
const a = after[field] || {}
const names = [...new Set([...Object.keys(b), ...Object.keys(a)])]
.filter(n => !isDeepStrictEqual(b[n], a[n]))
.sort()
if (names.length) {
applied[field] = names
changed = true
}
}
return changed ? applied : null
}
}
// Whether a directory contains any default .npm-extension file; a non-throwing existence check used for non-root workspace warnings.
const hasExtensionFile = dir => FORMATS.some(f => existsSync(resolve(dir, f.file)))
module.exports = NpmExtension
module.exports.NpmExtension = NpmExtension
module.exports.discover = discover
module.exports.hasExtensionFile = hasExtensionFile
module.exports.hashFile = hashFile
module.exports.EXTENSION_POINT = EXTENSION_POINT
+3
-1

@@ -231,3 +231,5 @@ const { join } = require('node:path')

// local `file:` deps (non-workspace fsChildren) should be treated as local dependencies, not external, so they get symlinked directly instead of being extracted into the store.
const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n)
// A file: dep surfaces as a Link edge whose resolved spec starts with file:; detect it from the edge so the target is treated as local even when it is absent from idealTree.fsChildren (a workspace consumer, or a target outside the repo root via npm link).
const fileLinkTargets = new Set(edges.filter(e => e.to?.isLink && e.to.resolved?.startsWith('file:')).map(e => e.to.target))
const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n) || fileLinkTargets.has(n)
const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target)

@@ -234,0 +236,0 @@

// mix-in implementing the loadActual method
const { dirname, join, normalize, relative, resolve } = require('node:path')
const { dirname, join, normalize, relative, resolve, sep } = require('node:path')

@@ -17,2 +17,3 @@ const PackageJson = require('@npmcli/package-json')

const PackageExtensions = require('../package-extensions.js')
const NpmExtension = require('../npm-extension.js')

@@ -75,2 +76,3 @@ // public symbols

calcDepFlags(tree, !options.root)
this.#repropagateOverrides()
this.actualTree = treeCheck(tree)

@@ -147,2 +149,3 @@ return this.actualTree

await this[_setWorkspaces](this.#actualTree)
this.#linkActualWorkspaces()

@@ -180,2 +183,6 @@ this.#transplant(root)

this.#linkActualWorkspaces()
// .npm-extension runs before packageExtensions, matching the ideal-tree resolution order
await this.#applyNpmExtension()
this.#applyPackageExtensions()

@@ -221,2 +228,44 @@

// Under the linked (isolated) strategy, workspaces the root does not depend on are not symlinked into the root node_modules, so neither the on-disk scan nor the hidden lockfile gives the root's workspace edges a node_modules/<ws> link to resolve to.
// Synthesize those links from the authoritative workspaces map so npm ls and npm query surface the workspaces, matching hoisted and the logical package-lock.
#linkActualWorkspaces () {
const root = this.#actualTree
if (this.options.installStrategy !== 'linked' || !root.workspaces) {
return
}
// Declared workspaces ARE symlinked at root node_modules under linked, so a missing link there is a real problem that must surface as UNMET.
// Only undeclared workspaces are intentionally absent from root node_modules and need a synthesized link so their root edges resolve.
const pkg = root.package
const declared = new Set(Object.keys(Object.assign({},
pkg.dependencies,
pkg.devDependencies,
pkg.optionalDependencies,
pkg.peerDependencies
)))
// index loaded workspace targets by both path and realpath, so a workspace reached through a symlinked dir still matches
const byLoc = new Map()
for (const node of root.fsChildren) {
byLoc.set(node.path, node)
byLoc.set(node.realpath, node)
}
for (const [name, path] of root.workspaces.entries()) {
// declared workspaces, and any name already a root child, resolve their edge without a synthesized link
if (declared.has(name) || root.children.has(name)) {
continue
}
const target = byLoc.get(path)
if (target) {
// eslint-disable-next-line no-new
new Link({
name,
path: resolve(root.path, 'node_modules', name),
realpath: target.realpath,
target,
parent: root,
root,
})
}
}
}
#transplant (root) {

@@ -274,2 +323,5 @@ if (!root || root === this.#actualTree) {

loadOverrides,
// A package physically located in the linked strategy's store is a transitive dependency, not a real tree top, so it must not load its devDependencies.
// Never flag the loaded root itself, even if its own path happens to sit under a .store directory.
isInStore: path !== this.path && real.includes(`${sep}node_modules${sep}.store${sep}`),
}

@@ -392,2 +444,47 @@

// Re-forward overrides through links once all edges are resolved, since a Link may forward before its subtree resolves and miss a transitive match (npm/cli#9619, #9659).
#repropagateOverrides () {
if (!this.#actualTree.overrides) {
return
}
for (const node of this.#actualTree.inventory.values()) {
if (node.isLink && node.overrides) {
node.recalculateOutEdgesOverrides()
}
}
}
// .npm-extension transformManifest, like packageExtensions, never rewrites a package's package.json, so re-derive its edges and provenance on a filesystem-scanned actual tree.
// This executes the root extension code; ignore-extension (and ignore-scripts via flatten) disables it.
async #applyNpmExtension () {
if (this.options.ignoreExtension) {
return
}
const ext = new NpmExtension({
root: this.#actualTree.realpath,
extensionFile: this.options.extensionFile,
})
if (!ext.present) {
return
}
await ext.load()
for (const node of this.#actualTree.inventory.values()) {
// only installed dependencies are transformed, never the root or a workspace
if (node.isLink || node.isProjectRoot || !node.name || !node.inNodeModules()) {
continue
}
const res = ext.apply(node.package)
if (res) {
node.package = res.pkg
node.npmExtensionApplied = res.applied
}
}
// mirror the provenance onto links so the logical tree location reports it too
for (const node of this.#actualTree.inventory.values()) {
if (node.isLink && node.target?.npmExtensionApplied) {
node.npmExtensionApplied = node.target.npmExtensionApplied
}
}
}
async #findMissingEdges () {

@@ -409,5 +506,6 @@ // try to resolve any missing edges by walking up the directory tree,

for (const [name, edge] of node.edgesOut.entries()) {
const notMissing = !edge.missing &&
!(edge.to && (edge.to.dummy || edge.to.parent !== node))
if (notMissing) {
// An unresolved optional edge reports missing === false, so check the target directly.
// Otherwise an installed optional dep that lives only as a store sibling is never loaded.
const resolved = edge.to && !edge.to.dummy && edge.to.parent === node
if (resolved) {
continue

@@ -414,0 +512,0 @@ }

@@ -247,2 +247,3 @@ const { resolve } = require('node:path')

packageExtensionsApplied: sw.packageExtensionsApplied,
npmExtensionApplied: sw.npmExtensionApplied,
resolved: consistentResolve(sw.resolved, this.path, path),

@@ -249,0 +250,0 @@ pkg: sw,

@@ -155,7 +155,2 @@ // an object representing the set of vulnerabilities in a tree

// we will have loaded the source already if this is a metavuln
if (advisory.type === 'metavuln') {
vuln.addVia(this.get(advisory.dependency))
}
// already marked this one, no need to do it again

@@ -223,2 +218,13 @@ if (vuln.nodes.has(node)) {

// post-loop reconciliation: establish all via links
// done here to ensure all vulns exist, avoid redundant setter triggers,
// and correctly handle multiple paths and omission cleanups.
for (const vuln of this.values()) {
for (const advisory of vuln.advisories) {
if (advisory.type === 'metavuln') {
vuln.addVia(this.get(advisory.dependency))
}
}
}
timeEnd()

@@ -225,0 +231,0 @@ }

@@ -38,8 +38,28 @@ // Dep flag (dev, peer, etc.) calculation requires default or reset flags.

}
node.target.dev = node.dev
node.target.optional = node.optional
node.target.devOptional = node.devOptional
node.target.peer = node.peer
node.target.extraneous = node.extraneous
queue.push(node.target)
// Only unset flags, matching the edge walk below, so multiple links to one target can't clobber a correct value.
let changed = false
if (node.target.dev && !node.dev) {
node.target.dev = false
changed = true
}
if (node.target.optional && !node.optional) {
node.target.optional = false
changed = true
}
if (node.target.devOptional && !node.devOptional) {
node.target.devOptional = false
changed = true
}
if (node.target.peer && !node.peer) {
node.target.peer = false
changed = true
}
if (node.target.extraneous && !node.extraneous) {
node.target.extraneous = false
changed = true
}
// queue target on first visit so its deps are walked
if (changed || !seen.has(node.target)) {
queue.push(node.target)
}
continue

@@ -46,0 +66,0 @@ }

@@ -163,3 +163,3 @@ // An edge in the dependency graph

explanation.from = this.#from.explain(null, seen)
// note when this edge was created by a root packageExtensions repair on the from node
// note when this edge was created or changed by a root packageExtensions repair on the from node
const applied = this.#from.packageExtensionsApplied

@@ -174,2 +174,12 @@ if (applied) {

}
// note when this edge was created or changed by a root .npm-extension transformManifest repair on the from node
const transformed = this.#from.npmExtensionApplied
if (transformed) {
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']) {
if (transformed[field]?.includes(this.#name)) {
explanation.npmExtension = { extensionPoint: transformed.extensionPoint, field }
break
}
}
}
}

@@ -176,0 +186,0 @@ this.#explanation = explanation

@@ -8,1 +8,2 @@ module.exports = require('./arborist/index.js')

module.exports.PackageExtensions = require('./package-extensions.js')
module.exports.NpmExtension = require('./npm-extension.js')

@@ -5,9 +5,2 @@ // Alternate versions of different classes that we use for isolated mode

// fake lib/inventory.js
class IsolatedInventory extends Map {
query () {
return []
}
}
// fake lib/node.js

@@ -21,3 +14,3 @@ class IsolatedNode {

integrity = null
inventory = new IsolatedInventory()
inventory = new Map()
isInStore = false

@@ -24,0 +17,0 @@ inBundle = false

@@ -112,5 +112,3 @@ const relpath = require('./relpath.js')

// When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut — but only when the OverrideSet has at least one rule that names a dep the target actually depends on.
// Without this scope, the link forwards a generic ancestor OverrideSet that has no real effect on the target's edges, but still flips the target to "has overrides", which changes downstream `canReplaceWith` / placement decisions and causes `npm ci` to re-resolve lockfile-pinned edges from the registry.
// See npm/cli#9357.
// Forward overrides to the target only when a rule names a dep it needs, directly or transitively (npm/cli#9357, #9619).
recalculateOutEdgesOverrides () {

@@ -120,10 +118,3 @@ if (!this.target || !this.overrides) {

}
let hasMatchingRule = false
for (const rule of this.overrides.ruleset.values()) {
if (this.target.edgesOut.has(rule.name)) {
hasMatchingRule = true
break
}
}
if (!hasMatchingRule) {
if (!overrideMatchesSubtree(this.overrides, this.target)) {
return

@@ -148,2 +139,27 @@ }

// True when an override rule applies to an edge reachable from the target at any depth.
// getEdgeRule matches on name and spec, returning the set itself when nothing applies, so a non-applicable version-qualified rule doesn't flip an intermediate node to "has overrides" (npm/cli#9357).
// Not a method: runs from the Node super-constructor, before subclass private methods exist.
const overrideMatchesSubtree = (overrides, target) => {
const seen = new Set()
const stack = [target]
while (stack.length) {
const node = stack.pop()
const resolved = node.isLink ? node.target : node
if (seen.has(resolved)) {
continue
}
seen.add(resolved)
for (const edge of resolved.edgesOut.values()) {
if (overrides.getEdgeRule(edge).name === edge.name) {
return true
}
if (edge.to) {
stack.push(edge.to)
}
}
}
return false
}
module.exports = Link

@@ -97,2 +97,3 @@ // inventory, path, realpath, root, and parent

packageExtensionsApplied = null,
npmExtensionApplied = null,
parent,

@@ -180,2 +181,5 @@ patched = null,

this.packageExtensionsApplied = packageExtensionsApplied
// Provenance for a root .npm-extension transformManifest repair applied to this node's manifest, or null.
// Shape: { extensionPoint, dependencies?, optionalDependencies?, peerDependencies?, peerDependenciesMeta? }.
this.npmExtensionApplied = npmExtensionApplied
this.installLinks = installLinks

@@ -937,3 +941,4 @@ this.legacyPeerDeps = legacyPeerDeps

} = sourceReference || {}
const thisDev = isTop && !globalTop && path
// A package in the linked strategy's .store is a transitive dependency that is structurally a tree top, but its devDependencies are never installed or required, so they must not be loaded.
const thisDev = isTop && !globalTop && path && !this.isInStore
const srcDev = !sourceReference || srcTop && !srcGlobalTop && srcPath

@@ -940,0 +945,0 @@ if (thisDev && srcDev) {

@@ -253,2 +253,5 @@ // Given a dep, a node that depends on it, and the edge representing that

: {}),
...(this.dep.npmExtensionApplied
? { npmExtensionApplied: this.dep.npmExtensionApplied }
: {}),
...(this.dep.overrides ? { overrides: this.dep.overrides } : {}),

@@ -255,0 +258,0 @@ ...(this.dep.isLink ? { target: this.dep.target, realpath: this.dep.realpath } : {}),

@@ -801,3 +801,4 @@ 'use strict'

for (const link of node.linksIn) {
if (link.parent === compareNode) {
// A store-backing node (linked strategy) is reached through its logical Link, which matches via edgesIn below, so the store node itself is never a direct child.
if (link.parent === compareNode && !node.isInStore) {
return true

@@ -804,0 +805,0 @@ }

@@ -46,3 +46,5 @@ const npa = require('npm-package-arg')

for (const [key, value] of Object.entries(policy)) {
if (!matches(node, key)) {
// Pass deny intent so matchRegistry can fail closed on an unverifiable
// version: a deny still blocks, an allow stays refused.
if (!matches(node, key, value === false)) {
continue

@@ -70,3 +72,3 @@ }

const matches = (node, key) => {
const matches = (node, key, failClosed) => {
let parsed

@@ -83,3 +85,3 @@ try {

case 'version':
return matchRegistry(node, parsed)
return matchRegistry(node, parsed, failClosed)
case 'git':

@@ -138,3 +140,3 @@ return matchGit(node, parsed)

const matchRegistry = (node, parsed) => {
const matchRegistry = (node, parsed, failClosed) => {
// If this node is not a registry dep, refuse the match. A registry-style

@@ -175,5 +177,10 @@ // key (`pkg`, `pkg@1`, `pkg@1 || 2`) must not match a tarball or git node

}
if (!trusted.version || !isExactVersionDisjunction(parsed.fetchSpec)) {
if (!isExactVersionDisjunction(parsed.fetchSpec)) {
return false
}
// Unverifiable version (omit-lockfile-registry-resolved): a deny blocks,
// an allow is refused.
if (!trusted.version) {
return failClosed
}
return semver.satisfies(trusted.version, parsed.fetchSpec, { loose: true })

@@ -186,2 +193,6 @@ }

if (parsed.type === 'version') {
// Unverifiable version: a deny blocks, an allow is refused.
if (!trusted.version) {
return failClosed
}
return trusted.version === parsed.fetchSpec

@@ -375,2 +386,3 @@ }

module.exports.isScriptAllowed = isScriptAllowed
module.exports.matches = matches
module.exports.isExactVersionDisjunction = isExactVersionDisjunction

@@ -377,0 +389,0 @@ module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity

@@ -118,2 +118,3 @@ // a module that manages a lockfile (package-lock.json).

'packageExtensionsApplied',
'npmExtensionApplied',
]

@@ -181,2 +182,31 @@

// The walk above can't reach two linked-strategy layouts: a store package's sibling deps under .store/<key>/node_modules (.store is a skipped dot-dir), and an undeclared workspace not symlinked into root node_modules. Walk those dirs, derived from the lockfile entries.
// A dir reachable through a link entry is skipped: it was already reached (or, if the symlink is stale, correctly stays unseen so the lockfile is rejected). This keeps the hoisted strategy's stale-symlink detection intact, since there every workspace is a link target.
const linkTargets = new Set()
for (const loc in data.packages) {
const { link, resolved } = data.packages[loc]
if (link && resolved) {
linkTargets.add(resolved.replace(/\\/g, '/'))
}
}
const extraDirs = new Set()
for (const loc in data.packages) {
const store = loc.match(/^(.*\/\.store\/.+?)\/node_modules\//)
if (store) {
// .store/<key> is never walked but has no entry, so mark it seen.
seen.add(store[1])
extraDirs.add(`${store[1]}/node_modules`)
continue
}
// A workspace/fsChild dir outside node_modules, e.g. packages/a.
const i = loc.indexOf('/node_modules/')
const root = i === -1 ? loc : loc.slice(0, i)
if (root && !/(^|\/)node_modules(\/|$)/.test(root) && !linkTargets.has(root)) {
extraDirs.add(root)
}
}
for (const rel of extraDirs) {
await assertNoNewer(path, data, lockTime, resolve(path, rel), seen)
}
// assert that all the entries in the lockfile were seen

@@ -361,2 +391,3 @@ for (const loc in data.packages) {

this.packageExtensionsHash = null
this.npmExtensionHash = null
const lockfileVersion = this.lockfileVersion || defaultLockfileVersion

@@ -502,2 +533,4 @@ this.originalLockfileVersion = lockfileVersion

this.packageExtensionsHash = data.packages?.['']?.packageExtensionsHash || null
// the .npm-extension file hash, if the lockfile recorded one on its root entry
this.npmExtensionHash = data.packages?.['']?.npmExtensionHash || null

@@ -925,2 +958,6 @@ // use default if it wasn't explicitly set, and the current file is

}
// record the .npm-extension file hash on the root entry for the same reason
if (this.npmExtensionHash) {
root.npmExtensionHash = this.npmExtensionHash
}
this.data.packages = {}

@@ -979,8 +1016,8 @@ if (Object.keys(root).length) {

}
// packageExtensions state likewise forces lockfileVersion 4 so older clients abort instead of dropping the repaired graph
// packageExtensions and .npm-extension state likewise force lockfileVersion 4 so older clients abort instead of dropping the repaired graph
const hasExtensionState = !this.hiddenLockfile &&
(this.packageExtensionsHash ||
Object.values(this.data.packages).some(p => p.packageExtensionsApplied))
(this.packageExtensionsHash || this.npmExtensionHash ||
Object.values(this.data.packages).some(p => p.packageExtensionsApplied || p.npmExtensionApplied))
if (hasExtensionState && this.lockfileVersion < packageExtensionsLockfileVersion) {
log.warn('shrinkwrap', `packageExtensions requires lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`)
log.warn('shrinkwrap', `manifest extensions require lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`)
this.lockfileVersion = packageExtensionsLockfileVersion

@@ -987,0 +1024,0 @@ }

@@ -53,2 +53,9 @@ const isScriptAllowed = require('./script-allowed.js')

}
if (node.inert) {
// Inert = an optional dep that can't be installed here (failed the
// os/cpu/libc or engine check, or failed to load). reify drops it
// before any script runs, so its install scripts never execute and it
// must not be flagged (npm/cli#9562).
continue
}

@@ -55,0 +62,0 @@ const verdict = isScriptAllowed(node, resolvedPolicy)

{
"name": "@npmcli/arborist",
"version": "10.0.0-pre.1",
"version": "10.0.0-pre.2",
"description": "Manage node_modules trees",

@@ -5,0 +5,0 @@ "dependencies": {

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display