feat(#1711): no-posix-mode-bit-assert AST rule (Phase 2) (#1718)

Phase 2 of epic #1702. Closes #1711.
This commit is contained in:
Tom Boucher
2026-06-25 15:26:23 -04:00
committed by GitHub
parent a72dbfa58c
commit 481d121dd4
6 changed files with 990 additions and 2 deletions

View File

@@ -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`

View File

@@ -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

View 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;

View File

@@ -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',

View 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: [],
});
});
});

View File

@@ -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.
];