@@ -736,9 +736,9 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
|
||||
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom=a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === <that exact octal>; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples=#1634/PR #1638 tests/capability-lifecycle.test.cjs "a .cjs hook command is node-prefixed so it runs without the executable bit" failed windows-latest,24 on "precondition: file staged without +x" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect=grep tests for \`.mode & 0o777\` / \`.mode) === 0o\` / \`writeFileSync(...{ mode: 0o\` / \`chmodSync\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666)`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect=grep tests for \`.mode & 0o777\` / \`.mode) === 0o\` / \`writeFileSync(...{ mode: 0o\` / \`chmodSync\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward=gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention=ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (lint-windows-test-portability) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention=ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit`
|
||||
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom=path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected`
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples=PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\...\gsd-ial-windsurf-XXX\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only`
|
||||
|
||||
@@ -17,6 +17,7 @@ running outside ESLint, fails the build if you try). Legitimately platform-speci
|
||||
| Rule | Flags | Surface |
|
||||
|---|---|---|
|
||||
| `local/no-path-literal-in-assert` | An `assert.equal`/`strictEqual`/`deepEqual`/`deepStrictEqual` or `expect(...).toBe`/`toEqual`/`toStrictEqual` where one operand is a **path-returning function call** and the other is a **hardcoded `/`-string literal** not normalized to POSIX. | `tests/**/*.test.cjs` |
|
||||
| `local/no-posix-mode-bit-assert` | An equality assertion comparing a file **`.mode`** (e.g. `statSync(p).mode & 0o777`) to an **octal literal** — Windows reports `0o666`/`0o444`, never the requested mode. | `tests/**/*.test.cjs` |
|
||||
|
||||
(More rules land per the epic — see ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702).)
|
||||
|
||||
@@ -50,6 +51,26 @@ together). Recognized normalizers: `.replace(/\\/g,'/')`, `.replace(/[\\/]/g,'/'
|
||||
`.replaceAll('\\','/')`, `.replaceAll(path.sep,'/')`, `.split(path.sep).join('/')`,
|
||||
`toPosixPath(...)`.
|
||||
|
||||
## How-to — fix a `no-posix-mode-bit-assert` violation
|
||||
|
||||
Windows does not honor POSIX file modes — `fs.statSync(p).mode` reads back `0o666` (writable) or
|
||||
`0o444` (readonly), never the `0o644`/`0o755` you wrote. A mode-bit assertion is therefore a
|
||||
POSIX-only precondition. **Gate it behind a platform check and keep the real behavioral assertion
|
||||
running on every OS** (do not delete it — scope it):
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);
|
||||
|
||||
// ✅ scope the POSIX-only precondition; keep the behavioral assertion cross-platform
|
||||
if (process.platform !== 'win32') {
|
||||
assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);
|
||||
}
|
||||
assert.match(hookCommand, /^node /); // behavioral assertion — runs everywhere
|
||||
```
|
||||
|
||||
Prefer asserting the *behavior* (command shape, runnability) over the raw mode bit where you can.
|
||||
|
||||
## Platform guards (the only "escape" — by structure, not annotation)
|
||||
|
||||
If an assertion is *genuinely* POSIX-only, gate it behind a Windows platform check the rule
|
||||
@@ -72,6 +93,10 @@ binding-aware (a reassigned or `false`-initialized variable is not trusted), and
|
||||
`os.platform()` and `node:test` skip returns. See
|
||||
[`eslint-rules/lib/platform-guard.cjs`](../../eslint-rules/lib/platform-guard.cjs).
|
||||
|
||||
> **Note:** the `node:test` `test(name, { skip: isWindows ? … : false }, fn)` *option* object is
|
||||
> NOT recognized as a platform guard. To scope a POSIX-only assertion use an
|
||||
> `if (process.platform !== 'win32')` guard (or early-return) **inside** the callback.
|
||||
|
||||
## How-to — add a new path resolver
|
||||
|
||||
When you add a function that returns a filesystem path (e.g. in `src/runtime-homes.cts`), add its
|
||||
|
||||
409
eslint-rules/no-posix-mode-bit-assert.cjs
Normal file
409
eslint-rules/no-posix-mode-bit-assert.cjs
Normal file
@@ -0,0 +1,409 @@
|
||||
'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;
|
||||
@@ -16,6 +16,7 @@ import noRawRmsyncInTests from './eslint-rules/no-raw-rmsync-in-tests.cjs';
|
||||
import noTautologicalAssert from './eslint-rules/no-tautological-assert.cjs';
|
||||
import noAdhocMarkdownParsing from './eslint-rules/no-adhoc-markdown-parsing.cjs';
|
||||
import noPathLiteralInAssert from './eslint-rules/no-path-literal-in-assert.cjs';
|
||||
import noPosixModeBitAssert from './eslint-rules/no-posix-mode-bit-assert.cjs';
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
@@ -26,6 +27,7 @@ const localPlugin = {
|
||||
'no-tautological-assert': noTautologicalAssert,
|
||||
'no-adhoc-markdown-parsing': noAdhocMarkdownParsing,
|
||||
'no-path-literal-in-assert': noPathLiteralInAssert,
|
||||
'no-posix-mode-bit-assert': noPosixModeBitAssert,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -269,6 +271,8 @@ export default tseslint.config(
|
||||
'local/no-source-grep': 'error',
|
||||
// Ban path-returning calls compared to hardcoded POSIX-slash literals (fails on Windows)
|
||||
'local/no-path-literal-in-assert': 'error',
|
||||
// Ban POSIX mode-bit assertions compared to octal literals (fails on Windows)
|
||||
'local/no-posix-mode-bit-assert': 'error',
|
||||
// Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
|
||||
549
tests/no-posix-mode-bit-assert.rule.test.cjs
Normal file
549
tests/no-posix-mode-bit-assert.rule.test.cjs
Normal file
@@ -0,0 +1,549 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-posix-mode-bit-assert.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-posix-mode-bit-assert ESLint rule.
|
||||
* Mirrors the style of tests/no-path-literal-in-assert.rule.test.cjs.
|
||||
*
|
||||
* Rule: report when a file-mode expression is compared to an octal literal in
|
||||
* an assert.*() or expect(…).<matcher>() assertion — a DEFECT that fails on
|
||||
* Windows because Windows reports 0o666/0o444 (DOS attributes), never the
|
||||
* requested POSIX octal.
|
||||
*
|
||||
* "File-mode expression" is:
|
||||
* M0. Direct/chained `.mode` MemberExpression (non-computed)
|
||||
* M1. Computed member `x['mode']`
|
||||
* M2. Variable capture: `const m = stat.mode` / `const m = stat['mode']`
|
||||
* resolved via scope — m & 0o777 or m === 0o644 is flagged.
|
||||
* Unresolvable bare identifier (no in-file binding) is NOT flagged.
|
||||
* M3. Destructure: `const { mode } = fs.statSync(p)` — mode binding flagged.
|
||||
* M4. Wrapper: `Number(stat.mode & 0o777)` / `parseInt(stat.mode, 8)` inside
|
||||
* the assertion operand — recurses into the wrapper's first argument.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT
|
||||
*
|
||||
* VALID (no report) when:
|
||||
* - the assertion is inside a Windows-excluded block (platform guard,
|
||||
* early-return guard, hoisted isWindows) as detected by platform-guard.cjs
|
||||
* - neither operand resolves to a mode expression
|
||||
* - the mode field is compared to a variable (not an octal literal)
|
||||
* - a bare Identifier whose binding is unresolvable in-file is NOT flagged
|
||||
* (conservative — avoids false positives on non-fs identifiers)
|
||||
*
|
||||
* Note on non-fs `.mode`: the rule intentionally flags ANY `.mode`-vs-octal-
|
||||
* literal equality assertion. It cannot distinguish `fs.statSync().mode` from
|
||||
* an unrelated `obj.mode`, and a non-fs `.mode` compared to an octal literal
|
||||
* is vanishingly rare in test code. The defect shape (POSIX-mode assertion that
|
||||
* fails on Windows) is the primary concern.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const noPosixModeBitAssert = require('../eslint-rules/no-posix-mode-bit-assert.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-posix-mode-bit-assert rule module', () => {
|
||||
test('exports meta and create', () => {
|
||||
assert.strictEqual(typeof noPosixModeBitAssert.meta, 'object');
|
||||
assert.strictEqual(typeof noPosixModeBitAssert.create, 'function');
|
||||
assert.strictEqual(noPosixModeBitAssert.meta.type, 'problem');
|
||||
assert.ok(noPosixModeBitAssert.meta.messages.posixModeBit);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── INVALID cases (violation expected) ───────────────────────────────────────
|
||||
|
||||
describe('no-posix-mode-bit-assert invalid cases', () => {
|
||||
test('invalid: assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: assert.equal(statSync(p).mode & 0o111, 0)', () => {
|
||||
// 0 is a decimal literal but the mode mask 0o111 is an octal — the mask side
|
||||
// determines the defect shape (bitwise mask on .mode with an octal).
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.equal(statSync(p).mode & 0o111, 0);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: direct .mode in the assertion — assert.strictEqual(fs.statSync(p).mode & 0o777, 0o755)', () => {
|
||||
// The assertion operand contains `.mode` directly (not via a variable).
|
||||
// This is always detected regardless of surrounding context.
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// .mode is directly in the masked expression inside the assert
|
||||
code: `const m = fs.statSync(p).mode; assert.strictEqual(fs.statSync(p).mode & 0o777, 0o755);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: assert.strictEqual(fs.statSync(p).mode, 0o100644) — direct mode comparison', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(fs.statSync(p).mode, 0o100644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: expect(statSync(p).mode & 0o777).toBe(0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `expect(statSync(p).mode & 0o777).toBe(0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: assert.deepEqual with mode mask', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.deepEqual(fs.statSync(p).mode & 0o777, 0o755);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: assert.deepStrictEqual with direct mode comparison', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.deepStrictEqual(fs.statSync(f).mode, 0o100755);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: expect(statSync(p).mode & 0o777).toEqual(0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `expect(statSync(p).mode & 0o777).toEqual(0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: expect(statSync(p).mode & 0o777).toStrictEqual(0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `expect(statSync(p).mode & 0o777).toStrictEqual(0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test.skip('invalid: legacy octal (0644) — espree ecmaVersion:2022 rejects the syntax; out of scope', () => {});
|
||||
|
||||
test('invalid: x.mode compared to octal (simple MemberExpression .mode)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(x.mode, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: reversed operands — assert.strictEqual(0o644, fs.statSync(p).mode & 0o777)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(0o644, fs.statSync(p).mode & 0o777);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ── M1: computed member x['mode'] ─────────────────────────────────────────
|
||||
|
||||
test('invalid M1: assert.strictEqual(stat["mode"] & 0o777, 0o644) — computed member', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(stat['mode'] & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid M1: assert.strictEqual(fs.statSync(p)["mode"], 0o100644) — computed member direct', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(fs.statSync(p)['mode'], 0o100644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ── M2: variable capture const m = stat.mode ──────────────────────────────
|
||||
|
||||
test('invalid M2: const m = fs.statSync(p).mode; assert.strictEqual(m & 0o777, 0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const m = fs.statSync(p).mode; assert.strictEqual(m & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid M2: const m = stat["mode"]; assert.strictEqual(m & 0o777, 0o644) — computed-member capture', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const m = stat['mode']; assert.strictEqual(m & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid M2: const m = fs.statSync(p).mode; assert.strictEqual(m, 0o100644) — direct comparison', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const m = fs.statSync(p).mode; assert.strictEqual(m, 0o100644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ── M3: destructuring const { mode } = fs.statSync(p) ────────────────────
|
||||
|
||||
test('invalid M3: const { mode } = fs.statSync(p); assert.strictEqual(mode & 0o777, 0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { mode } = fs.statSync(p); assert.strictEqual(mode & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid M3: const { mode } = lstatSync(p); assert.strictEqual(mode, 0o100755)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { mode } = lstatSync(p); assert.strictEqual(mode, 0o100755);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ── M4: wrapper Number(...) / parseInt(...) ────────────────────────────────
|
||||
|
||||
test('invalid M4: assert.strictEqual(Number(fs.statSync(p).mode & 0o777), 0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(Number(fs.statSync(p).mode & 0o777), 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid M4: assert.strictEqual(parseInt(fs.statSync(p).mode, 8) & 0o777, 0o644)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(parseInt(fs.statSync(p).mode, 8) & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid M4: assert.strictEqual(Number(stat.mode), 0o644) — Number wrapper direct', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `assert.strictEqual(Number(stat.mode), 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'posixModeBit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID cases (no violation expected) ─────────────────────────────────────
|
||||
|
||||
describe('no-posix-mode-bit-assert valid cases', () => {
|
||||
test('valid: guarded by if (process.platform !== "win32") { ... }', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
if (process.platform !== 'win32') {
|
||||
assert.strictEqual(statSync(p).mode & 0o777, 0o644);
|
||||
}
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: early-return guard — if (process.platform === "win32") return; assert.strictEqual(mode)', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
function test() {
|
||||
if (process.platform === 'win32') return;
|
||||
assert.strictEqual(statSync(p).mode & 0o777, 0o644);
|
||||
}
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.equal(config.timeout, 0o644) — octal but no .mode → not flagged', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.equal(config.timeout, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.equal(result.mode, "r") — .mode but no octal → not flagged', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.equal(result.mode, 'r');`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.equal(file.mode, expectedMode) — .mode but expected is a variable → not flagged', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.equal(file.mode, expectedMode);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.ok(fs.statSync(p).mode) — not an equality assertion', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.ok(fs.statSync(p).mode);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.equal(x, y) — no .mode, no octal', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.equal(x, y);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.strictEqual(count, 0o777) — octal but no .mode in assertion operands → not flagged', () => {
|
||||
// 0o777 is an octal, count is a bare identifier, no .mode → out of scope
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.strictEqual(count, 0o777);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.notStrictEqual(fs.statSync(p).mode & 0o777, 0o644) — inequality assertion, not flagged', () => {
|
||||
// Inequality assertions pass on Windows regardless, so are out of scope.
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.notStrictEqual(fs.statSync(p).mode & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: hoisted isWindows guard', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const isWindows = process.platform === 'win32';
|
||||
if (!isWindows) {
|
||||
assert.strictEqual(statSync(p).mode & 0o777, 0o644);
|
||||
}
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: assert.equal(result.mode, result.mode) — no octal involved', () => {
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.equal(result.mode, result.mode);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid (conservative): unresolvable bare identifier m & 0o777 — no in-file binding → NOT flagged', () => {
|
||||
// `m` has no in-file `const m = …mode` declaration (it could be an import,
|
||||
// a function parameter, or an unrelated local). The rule is conservative:
|
||||
// it only flags when the binding RESOLVES to a mode expression in-file.
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `assert.strictEqual(m & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid (conservative): variable capture with non-mode init — assert.strictEqual(m & 0o777, 0o644) NOT flagged when m = config.timeout', () => {
|
||||
// `m` is initialized to something that is NOT a mode expression; should not flag.
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `const m = config.timeout; assert.strictEqual(m & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid (conservative): M2 variable reassigned before assert — NOT flagged (reassignment invalidates alias)', () => {
|
||||
// `m` was initialized to a mode expression but then reassigned; conservative
|
||||
// approach — do not flag when init cannot be trusted as the current value.
|
||||
ruleTester.run('no-posix-mode-bit-assert', noPosixModeBitAssert, {
|
||||
valid: [
|
||||
{
|
||||
code: `const m = fs.statSync(p).mode; m = 0; assert.strictEqual(m & 0o777, 0o644);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const { globSync } = require('glob');
|
||||
// ── Protected portability rules (grows with each ADR-1703 phase) ──────────────
|
||||
const PROTECTED_RULES = [
|
||||
'no-path-literal-in-assert',
|
||||
'no-posix-mode-bit-assert',
|
||||
// Future phases: add new local/ portability rules here.
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user