Skip to content

fix: respect gypfile:false when detecting install scripts - #10061

Open
JamieMagee wants to merge 2 commits into
npm:latestfrom
JamieMagee:fix/gypfile-false-lockfiles
Open

JamieMagee wants to merge 2 commits into
npm:latestfrom
JamieMagee:fix/gypfile-false-lockfiles

Conversation

@JamieMagee

Copy link
Copy Markdown
Contributor

Arborist reads the installed package before inferring node-gyp rebuild, so lockfile installs respect gypfile: false and avoid false blocked-script warnings. The fix covers linked installs and scripts: null, with regression tests for stale script flags. Explicit scripts and the lockfile format are unchanged.

References

Fixes #9837

@JamieMagee
JamieMagee requested a review from a team as a code owner October 2, 2026 05:25
@JamieMagee
JamieMagee force-pushed the fix/gypfile-false-lockfiles branch from fc8a3a2 to 9e4f5b9 Compare October 2, 2026 21:48
Comment thread workspaces/arborist/lib/install-scripts.js
Comment thread workspaces/arborist/lib/arborist/rebuild.js
Comment thread docs/lib/content/configuring-npm/package-json.md Outdated
Comment thread workspaces/arborist/lib/install-scripts.js Outdated
Comment thread docs/lib/content/configuring-npm/package-json.md Outdated
Comment thread workspaces/arborist/lib/install-scripts.js
@JamieMagee

JamieMagee commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Strict preflight now uses only incoming metadata and preserves known script bodies. Linked installs sync rebuilt metadata back to logical nodes, including stale hasInstallScript cleanup for transitive dependencies. Regressions cover rejection before mutation for same-version replacements, plus fresh, clean, and repeated transitive linked installs. I also restored the package-root wording.

@manzoorwanijk manzoorwanijk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me now.

const includePrepare = hasNonRegistryShape(node)
const anyScript = scripts.preinstall || scripts.install || scripts.postinstall ||
(includePrepare && scripts.prepare)
let isGyp = readFromDisk && !scripts.preinstall && !scripts.install && gypfile !== false &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight callers (install, ci, libnpmexec) pass readFromDisk: false, so this line never probes node.path for binding.gyp. That is right for extracted packages, where node.path can hold an older same-name/version install. A directory dependency is linked in place and never extracted, so node.path is exactly what will be built.

Its package.json fields are already current in the tree (load-virtual re-reads link targets outside node_modules, and nodeFromSpec reads the directory). binding.gyp is the only signal preflight loses, and the lockfile can't stand in for it. A real lockfile entry for such a dependency is just "native": { "version": "1.0.0" } plus the link entry, with no hasInstallScript.

Repro (strict-allow-scripts, native/ has only package.json + binding.gyp, no allowScripts entry, no gypfile: false):

  • base: ESTRICTALLOWSCRIPTS, no link, no lockfile written
  • this head: npm install links the package and writes the lockfile, then only warns (native@1.0.0 (install: node-gyp rebuild)). npm ci has already removed node_modules.

This isn't an execution bypass, because allowScripts still blocks the script at rebuild. It does break strict mode's reject-before-mutation contract.

Suggested change
let isGyp = readFromDisk && !scripts.preinstall && !scripts.install && gypfile !== false &&
// A directory dependency is linked in place and never replaced, so unlike an
// extracted package its binding.gyp is always the one that will be built.
const probeDisk = readFromDisk || node.linksIn?.size > 0
let isGyp = probeDisk && !scripts.preinstall && !scripts.install && gypfile !== false &&
await isNodeGypPackage(node.path).catch(() => false)

linksIn is non-empty only for link targets. Preflight runs on the regular ideal tree (the isolated tree is swapped in later, inside reify()), so store links never show up here. The readFromDisk guard on the metadata recovery below stays as is. The ?. is there because the unit tests use plain-object nodes.

Regression tests

workspaces/arborist/test/install-scripts.js, after "preflight does not recover an opt-out from an older installed package". This also keeps the new linksIn branch covered.

t.test('preflight probes directory dependencies for binding.gyp in place', async t => {
  const getInstallScripts = require('../lib/install-scripts.js')
  // A directory dependency is linked in place, so its binding.gyp is the one
  // that will be built. An extracted package can still be replaced by reify.
  const linksIn = new Set([{ isLink: true }])
  const cases = [
    { name: 'native build', manifest: {}, linked: { install: 'node-gyp rebuild' } },
    { name: 'opt-out', manifest: { gypfile: false }, linked: {} },
  ]
  for (const { name, manifest, linked } of cases) {
    await t.test(name, async t => {
      const path = t.testdir({
        'package.json': JSON.stringify({ name: 'pkg', version: '1.0.0', ...manifest }),
        'binding.gyp': '',
      })
      const node = { path, package: { name: 'pkg', version: '1.0.0', ...manifest } }
      t.strictSame(await getInstallScripts({ ...node, linksIn }, { readFromDisk: false }),
        linked, 'directory dependency')
      t.strictSame(await getInstallScripts(node, { readFromDisk: false }),
        {}, 'extracted package')
    })
  }
})

test/lib/commands/install.js, before exec commands:

t.test('strict preflight reads directory dependencies in place', async t => {
  for (const { name, manifest, rejects } of [
    { name: 'native build', manifest: {}, rejects: true },
    { name: 'gypfile:false', manifest: { gypfile: false }, rejects: false },
  ]) {
    await t.test(name, async t => {
      const { npm } = await loadMockNpm(t, {
        config: { audit: false, 'strict-allow-scripts': true },
        prefixDir: {
          'package.json': JSON.stringify({
            name: 'host',
            version: '1.0.0',
            dependencies: { native: 'file:./native' },
          }),
          native: {
            'package.json': JSON.stringify({ name: 'native', version: '1.0.0', ...manifest }),
            'binding.gyp': '',
          },
        },
      })
      const link = path.join(npm.prefix, 'node_modules/native')
      const lockfile = path.join(npm.prefix, 'package-lock.json')
      if (rejects) {
        await t.rejects(npm.exec('install', []), {
          code: 'ESTRICTALLOWSCRIPTS',
          message: /native@1\.0\.0 \(install: node-gyp rebuild\)/,
        })
        t.notOk(fs.existsSync(link), 'does not link the dependency')
        t.notOk(fs.existsSync(lockfile), 'does not write a lockfile')
      } else {
        await npm.exec('install', [])
        t.ok(fs.lstatSync(link).isSymbolicLink(), 'links the dependency')
      }
    })
  }
})

ci and libnpmexec variants follow the same shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Incorrect detection of install script for better-sqlite3@13.0.2?

5 participants