Files
msd-core/eslint-rules/no-bare-npm-exec.cjs

155 lines
4.9 KiB
JavaScript

'use strict';
/**
* no-bare-npm-exec
*
* Flag bare 'npm' invocations via execFileSync/spawnSync/spawn without
* `shell: true`. On Windows, `npm` is `npm.cmd` — a CMD batch file — and
* cannot be launched without a shell.
*
* ## What this enforces (G5)
*
* - `execFileSync('npm', ...)` / `spawnSync('npm', ...)` / `spawn('npm', ...)`
* whose options object (last arg, if ObjectExpression) does NOT set
* `shell: true`, `shell: isWindows`, or `shell: process.platform === 'win32'`.
*
* ## What this does NOT flag
*
* - `execSync('npm install', ...)` — execSync always runs through a shell
* (cmd.exe on Windows automatically resolves npm.cmd), so it is safe without
* `shell: true`. Only direct binary exec functions (execFileSync, spawnSync,
* spawn) bypass the shell and require explicit `{ shell: true }`.
*
* Message: Windows needs `npm.cmd` — pass `{ shell: true }`.
*
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
*/
/** @type {import('eslint').Rule.RuleModule} */
const rule = {
meta: {
type: 'problem',
docs: {
description:
'Disallow bare "npm" execFileSync/spawnSync/spawn/execSync without shell:true (fails on Windows)',
category: 'Portability',
},
schema: [],
messages: {
bareNpmExec:
'Bare "npm" invocation without { shell: true } is not portable ' +
'(DEFECT.WINDOWS-TEST-PORTABILITY): On Windows, npm is a CMD batch file ' +
'(npm.cmd) and requires a shell to execute. Pass { shell: true } as the ' +
'options argument.',
},
},
create(context) {
/** Functions that take (command, args, options) — direct binary exec, no shell */
const EXEC_FILE_FNS = new Set(['execFileSync', 'spawnSync', 'spawn']);
/**
* Returns the string value of a Literal node, or null.
* @param {import('eslint').Rule.Node} node
* @returns {string|null}
*/
function stringValue(node) {
if (node && node.type === 'Literal' && typeof node.value === 'string') {
return node.value;
}
return null;
}
/**
* Returns the function name for a CallExpression callee (Identifier or
* MemberExpression), or null if not recognized.
* @param {import('eslint').Rule.Node} callee
* @returns {string|null}
*/
function getFnName(callee) {
if (callee.type === 'Identifier') return callee.name;
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier'
) {
return callee.property.name;
}
return null;
}
/**
* Returns true if an ObjectExpression has `shell: true`, `shell: isWindows`,
* or `shell: process.platform === 'win32'`.
* @param {import('eslint').Rule.Node} optionsNode
* @returns {boolean}
*/
function hasShellTrue(optionsNode) {
if (!optionsNode || optionsNode.type !== 'ObjectExpression') return false;
for (const prop of optionsNode.properties) {
if (prop.type !== 'Property') continue;
const keyName =
prop.key.type === 'Identifier'
? prop.key.name
: stringValue(prop.key);
if (keyName !== 'shell') continue;
const val = prop.value;
// shell: true
if (val.type === 'Literal' && val.value === true) return true;
// shell: isWindows / shell: IS_WINDOWS / shell: isWin / shell: onWindows
if (val.type === 'Identifier') {
const name = val.name;
if (
name === 'isWindows' ||
name === 'IS_WINDOWS' ||
name === 'isWin' ||
name === 'onWindows'
) {
return true;
}
}
// shell: process.platform === 'win32'
if (
val.type === 'BinaryExpression' &&
(val.operator === '===' || val.operator === '==') &&
val.left.type === 'MemberExpression' &&
val.left.object.type === 'Identifier' &&
val.left.object.name === 'process' &&
val.left.property.type === 'Identifier' &&
val.left.property.name === 'platform' &&
val.right.type === 'Literal' &&
val.right.value === 'win32'
) {
return true;
}
}
return false;
}
return {
CallExpression(node) {
const fnName = getFnName(node.callee);
if (!fnName) return;
const args = node.arguments;
if (!args || args.length === 0) return;
// Pattern A: execFileSync/spawnSync/spawn('npm', ...)
if (EXEC_FILE_FNS.has(fnName)) {
const firstArg = stringValue(args[0]);
if (firstArg !== 'npm') return;
// Find last ObjectExpression argument as the options
const lastArg = args[args.length - 1];
if (hasShellTrue(lastArg)) return;
// No shell:true — report
context.report({ node, messageId: 'bareNpmExec' });
}
},
};
},
};
module.exports = rule;