diff --git a/CONTEXT.md b/CONTEXT.md index 6c261800f..2d34acbcb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 === ; 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` diff --git a/docs/contributing/cross-platform-portability-rules.md b/docs/contributing/cross-platform-portability-rules.md index d1d15095d..4895a59c6 100644 --- a/docs/contributing/cross-platform-portability-rules.md +++ b/docs/contributing/cross-platform-portability-rules.md @@ -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 diff --git a/eslint-rules/no-posix-mode-bit-assert.cjs b/eslint-rules/no-posix-mode-bit-assert.cjs new file mode 100644 index 000000000..33b6a6ded --- /dev/null +++ b/eslint-rules/no-posix-mode-bit-assert.cjs @@ -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).(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.(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).(expected) ───────────────────────────── + // Shape: CallExpression{ callee: MemberExpression{ + // object: CallExpression{callee: Identifier{expect}}, + // property: Identifier{} + // }} + 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; diff --git a/eslint.config.mjs b/eslint.config.mjs index 743c1339d..1e0ddbb7d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/tests/no-posix-mode-bit-assert.rule.test.cjs b/tests/no-posix-mode-bit-assert.rule.test.cjs new file mode 100644 index 000000000..135465685 --- /dev/null +++ b/tests/no-posix-mode-bit-assert.rule.test.cjs @@ -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(…).() 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: [], + }); + }); +}); diff --git a/tests/portability-rule-disable-ban.test.cjs b/tests/portability-rule-disable-ban.test.cjs index 44effb105..da75af616 100644 --- a/tests/portability-rule-disable-ban.test.cjs +++ b/tests/portability-rule-disable-ban.test.cjs @@ -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. ];