Files
msd-core/eslint-rules/no-posix-mode-bit-assert.cjs
2026-06-25 15:26:23 -04:00

410 lines
16 KiB
JavaScript

'use strict';
/**
* no-posix-mode-bit-assert
*
* Flag assertion calls where a file-mode expression (e.g. fs.statSync(p).mode,
* or fs.statSync(p).mode & 0o777) is compared to an octal numeric literal.
* These assertions PASS on macOS/Linux but FAIL on Windows because Windows
* reports the DOS-attribute-derived mode (0o666 writable / 0o444 readonly),
* never the requested POSIX octal.
*
* Triggers on:
* assert.equal|strictEqual|deepEqual|deepStrictEqual(actual, expected)
* expect(actual).toBe|toEqual|toStrictEqual(expected)
*
* A "file-mode expression" is one that:
* M0. Contains a `.mode` MemberExpression (non-computed):
* x.mode, fs.statSync(p).mode, x.mode & 0oNNN
* M1. Contains a computed `['mode']` MemberExpression:
* x['mode'], stat['mode'] & 0o777
* M2. Is a variable whose binding (resolved via scope) is initialized to a
* mode expression: `const m = stat.mode` / `const m = stat['mode']`
* Conservative: only flags when binding resolves in-file and is not
* reassigned before the assertion.
* M3. Is a variable destructured as `mode` from an object:
* `const { mode } = fs.statSync(p)` — the `mode` binding is a mode expr.
* Conservative: same resolution rules as M2.
* M4. Is a CallExpression to `Number`/`parseInt` whose first argument contains
* a mode expression (recursive): `Number(stat.mode & 0o777)`,
* `parseInt(stat.mode, 8)`.
*
* The violation is flagged when:
* 1. One operand is (or contains/resolves-to) a mode expression, AND
* 2. An octal numeric literal appears either as the other operand, OR
* as the right-hand side of the bitwise expression containing the mode.
*
* Suppressed when:
* - The assertion node is inside a Windows-excluded block (platform guard,
* early-return guard, hoisted isWindows) as detected by platform-guard.cjs.
*
* DEFECT category: DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT
*
* ── Known boundaries ───────────────────────────────────────────────────────────
*
* (a) The rule detects `.mode` / `['mode']` by property name. It assumes any
* `.mode` or `['mode']` alongside an octal literal in an equality assertion
* is a filesystem mode check. A non-fs `.mode` or `['mode']` compared to an
* octal literal IS flagged — the defect shape (POSIX-mode assertion that
* fails on Windows) is the primary concern, and false positives for non-fs
* `.mode` vs an octal literal are vanishingly rare in test code.
*
* (b) Variable-capture (M2) and destructure (M3) detection is scope-based.
* When a binding RESOLVES in-file to a mode expression and is not reassigned,
* the variable is treated as a mode expression. An unresolvable or reassigned
* identifier is NOT flagged (conservative — avoids false positives on
* non-fs identifiers or imported constants).
*
* (c) The `node:test` `test(name, { skip: isWindows ? … : false }, fn)` OPTION
* object is NOT recognized as a platform guard. To make a mode-bit assertion
* POSIX-only use an `if (process.platform !== 'win32')` guard (or an early-
* return guard) inside the callback — the rule recognizes those shapes.
*
* (d) Octal detection covers `0o`/`0O` prefix literals. Legacy `0NNN` octal
* literals (banned by strict mode and most linters) are not a concern in
* modern test files and are not handled.
*/
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
/** @type {import('eslint').Rule.RuleModule} */
const rule = {
meta: {
type: 'problem',
docs: {
description:
'Disallow asserting POSIX file mode bits compared to octal literals (fails on Windows)',
category: 'Portability',
},
schema: [],
messages: {
posixModeBit:
'Asserting a POSIX file mode (DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT): Windows reports ' +
'0o666/0o444, not the requested octal. Gate this precondition on ' +
"`if (process.platform !== 'win32')` and keep the platform-independent " +
'behavioral assertion running on every OS.',
},
},
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
/** assert.equal / assert.strictEqual / assert.deepEqual / assert.deepStrictEqual */
const ASSERT_EQUALITY_METHODS = new Set([
'equal',
'strictEqual',
'deepEqual',
'deepStrictEqual',
]);
/** expect(actual).<matcher>(expected) */
const EXPECT_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']);
/**
* Returns true when the given AST node IS an octal numeric literal.
* Matches `0o`/`0O` prefix form (ES6+). Raw source is checked because
* `node.value` for `0o644` is `420` (decimal) — the same integer can be
* written as `0x1A4` or `420` without being a mode-bit assertion.
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function isOctalLiteral(node) {
if (!node || node.type !== 'Literal') return false;
if (typeof node.value !== 'number') return false;
// Check raw source representation via sourceCode
const raw = sourceCode.getText(node);
return raw.startsWith('0o') || raw.startsWith('0O');
}
/**
* Returns true when `node` is a syntactic mode expression — one that
* directly contains a `.mode` or `['mode']` MemberExpression anywhere
* within it (including inside BinaryExpression and Number/parseInt wrappers).
*
* Recognized shapes (M0, M1, M4):
* M0: x.mode — non-computed MemberExpression
* M0: fs.statSync(p).mode — chained non-computed
* M0: x.mode & 0o777 — .mode inside a BinaryExpression
* M1: x['mode'] — computed MemberExpression, string 'mode'
* M1: x['mode'] & 0o777 — computed .mode inside BinaryExpression
* M4: Number(x.mode & 0o777) — Number() wrapping a mode expression
* M4: parseInt(x.mode, 8) — parseInt() wrapping a mode expression
*
* Does NOT resolve variable references (that is done by isModeExpression).
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function containsSyntacticModeExpression(node) {
if (!node) return false;
// M0: Non-computed MemberExpression with property name 'mode'
if (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.type === 'Identifier' &&
node.property.name === 'mode'
) {
return true;
}
// M1: Computed MemberExpression with string property 'mode'
if (
node.type === 'MemberExpression' &&
node.computed &&
node.property.type === 'Literal' &&
node.property.value === 'mode'
) {
return true;
}
// BinaryExpression: recurse left and right (covers x.mode & 0o777)
if (node.type === 'BinaryExpression') {
return (
containsSyntacticModeExpression(node.left) ||
containsSyntacticModeExpression(node.right)
);
}
// M4: Number(...) or parseInt(...) — recurse into the first argument
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'Number' || node.callee.name === 'parseInt') &&
node.arguments.length >= 1
) {
return containsSyntacticModeExpression(node.arguments[0]);
}
return false;
}
/**
* Resolve a bare Identifier through the ESLint scope to determine whether
* its binding is initialized to a mode expression (M2/M3).
*
* Returns true when ALL of the following hold:
* - A VariableDeclarator binding for the name is found in-file scope.
* - The declarator's init is a mode expression:
* M2: `const m = stat.mode` / `const m = stat['mode']` — init is a
* MemberExpression (or expression) containing a mode MemberExpression.
* M3: `const { mode } = fs.statSync(p)` — the declarator id is an
* ObjectPattern that includes a property keyed 'mode' matching
* this identifier's name.
* - The variable is NOT reassigned after initialization.
*
* Returns false (conservative) when:
* - No in-file binding is found (could be an import, global, or parameter).
* - The binding does not resolve to a mode expression.
* - The variable is reassigned.
*
* @param {import('eslint').Rule.Node} identNode — the Identifier AST node
* @returns {boolean}
*/
function resolveIdentifierToModeExpression(identNode) {
if (!identNode || identNode.type !== 'Identifier') return false;
if (typeof sourceCode.getScope !== 'function') return false;
let scope;
try {
scope = sourceCode.getScope(identNode);
} catch (_) {
return false;
}
if (!scope) return false;
const name = identNode.name;
// Walk scope chain innermost-first to find the nearest binding.
let s = scope;
while (s) {
const variable = s.variables.find(v => v.name === name);
if (variable) {
// Found an in-file binding.
const defs = variable.defs;
if (!defs || defs.length === 0) return false; // no declarator (e.g. parameter)
const decl = defs[0].node; // VariableDeclarator
if (!decl) return false;
// Check for reassignment: any write reference that is NOT the init.
const isReassigned = variable.references.some(ref => ref.isWrite() && !ref.init);
if (isReassigned) return false;
// M3: ObjectPattern destructure — `const { mode } = ...`
// The binding matches if the declarator id is an ObjectPattern AND
// the destructured key for this identifier's name is 'mode'.
if (decl.id && decl.id.type === 'ObjectPattern') {
const modeProperty = decl.id.properties.find(
prop =>
prop.type === 'Property' &&
prop.key &&
((prop.key.type === 'Identifier' && prop.key.name === 'mode') ||
(prop.key.type === 'Literal' && prop.key.value === 'mode')) &&
prop.value &&
prop.value.type === 'Identifier' &&
prop.value.name === name
);
if (modeProperty) return true;
return false; // ObjectPattern without matching 'mode' key
}
// M2: Simple declarator — `const m = stat.mode` or `const m = stat['mode']`
if (!decl.init) return false;
return containsSyntacticModeExpression(decl.init);
}
s = s.upper;
}
// No in-file binding found — conservative: do not flag.
return false;
}
/**
* Returns true when `node` is or contains a file-mode expression.
* Extends containsSyntacticModeExpression with M2/M3 scope-based resolution
* for bare Identifiers.
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function isModeExpression(node) {
if (!node) return false;
// Syntactic check first (M0, M1, M4)
if (containsSyntacticModeExpression(node)) return true;
// M2/M3: bare Identifier — resolve via scope
if (node.type === 'Identifier') {
return resolveIdentifierToModeExpression(node);
}
// BinaryExpression: recurse (picks up `m & 0o777` where m is a mode alias)
if (node.type === 'BinaryExpression') {
return isModeExpression(node.left) || isModeExpression(node.right);
}
// M4: Number/parseInt — recurse into first argument
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'Number' || node.callee.name === 'parseInt') &&
node.arguments.length >= 1
) {
return isModeExpression(node.arguments[0]);
}
return false;
}
/**
* Returns true when `node` contains an octal literal anywhere within it.
* This covers:
* - 0o644 — direct octal literal
* - x.mode & 0o777 — octal inside a BinaryExpression (the mask)
* - Number(x.mode & 0o777) — octal inside a wrapper
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function containsOctalLiteral(node) {
if (!node) return false;
if (isOctalLiteral(node)) return true;
if (node.type === 'BinaryExpression') {
return containsOctalLiteral(node.left) || containsOctalLiteral(node.right);
}
// Also recurse into Number/parseInt wrappers for the octal check
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'Number' || node.callee.name === 'parseInt') &&
node.arguments.length >= 1
) {
return containsOctalLiteral(node.arguments[0]);
}
return false;
}
/**
* Returns true when the pair of operands represents a POSIX-mode-bit assertion:
* - One operand is (or resolves to) a mode expression, AND
* - An octal literal appears somewhere in either operand (as a mask or as
* the comparison value).
*
* Both operand orderings are checked by the caller.
*
* @param {import('eslint').Rule.Node} a - first operand
* @param {import('eslint').Rule.Node} b - second operand
* @returns {boolean}
*/
function isModeBitViolation(a, b) {
const aModeExpr = isModeExpression(a);
const bModeExpr = isModeExpression(b);
if (!aModeExpr && !bModeExpr) return false;
// At least one operand contains a .mode expression.
// Check if any octal literal appears in either operand.
const aHasOctal = containsOctalLiteral(a);
const bHasOctal = containsOctalLiteral(b);
return aHasOctal || bHasOctal;
}
return {
CallExpression(node) {
const callee = node.callee;
// ── assert.<method>(actual, expected) ──────────────────────────────
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.object.type === 'Identifier' &&
callee.object.name === 'assert' &&
callee.property.type === 'Identifier' &&
ASSERT_EQUALITY_METHODS.has(callee.property.name)
) {
const args = node.arguments;
if (args.length < 2) return;
const actual = args[0];
const expected = args[1];
if (isModeBitViolation(actual, expected) && !isWindowsExcludedNode(node, sourceCode)) {
context.report({ node, messageId: 'posixModeBit' });
}
return;
}
// ── expect(actual).<matcher>(expected) ─────────────────────────────
// Shape: CallExpression{ callee: MemberExpression{
// object: CallExpression{callee: Identifier{expect}},
// property: Identifier{<matcher>}
// }}
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier' &&
EXPECT_MATCHERS.has(callee.property.name) &&
callee.object.type === 'CallExpression' &&
callee.object.callee.type === 'Identifier' &&
callee.object.callee.name === 'expect' &&
callee.object.arguments.length === 1
) {
const actual = callee.object.arguments[0]; // the arg to expect(...)
const matcherArgs = node.arguments;
if (matcherArgs.length < 1) return;
const expected = matcherArgs[0];
if (isModeBitViolation(actual, expected) && !isWindowsExcludedNode(node, sourceCode)) {
context.report({ node, messageId: 'posixModeBit' });
}
return;
}
},
};
},
};
module.exports = rule;