diff --git a/.changeset/sturdy-deer-greet.md b/.changeset/sturdy-deer-greet.md new file mode 100644 index 000000000..18b217487 --- /dev/null +++ b/.changeset/sturdy-deer-greet.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2879 +--- +**The portability linter now catches Windows-path failures in membership and substring assertions** — `no-path-literal-in-assert` flags `.includes`/`.indexOf`/`.startsWith`/`.endsWith`/`.match` over a path-returning receiver (including through a `.map()` hop), not just equality assertions. Previously these passed lint and failed on Windows CI; the rule now surfaces them at lint time. (#2764) diff --git a/eslint-rules/no-path-literal-in-assert.cjs b/eslint-rules/no-path-literal-in-assert.cjs index e7d01ad4a..7b6a8ef4a 100644 --- a/eslint-rules/no-path-literal-in-assert.cjs +++ b/eslint-rules/no-path-literal-in-assert.cjs @@ -44,6 +44,14 @@ * path really does contain forward-slashes even on Windows), wrapping with * `String().replace(/\\\\/g, '/')` is the correct suppression; on POSIX * systems where `\\` never appears, the replace is a no-op and has zero cost. + * + * (d) #2764 membership/substring shape — `.includes/.indexOf/.startsWith/.endsWith` + * (string-literal arg) and `.match` (regex-literal arg) over a receiver that + * traces to a path-returning call (directly or via `.map()` hops). For `.match`, + * ANY regex literal whose source contains a `/` is treated as a POSIX-path + * membership check; a non-path regex containing `/` on a path-returning receiver + * (e.g. `.match(/https:\/\//)`) will also fire. The rule is scoped to the + * `tests/` test-file glob, so every such call is a test assertion shape. */ const { @@ -88,6 +96,77 @@ const rule = { /** expect(actual).(expected) */ const EXPECT_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']); + // ── #2764 helpers: membership / substring checks over a path-returner ────── + // .includes / .indexOf / .startsWith / .endsWith / .match on a receiver that + // traces to a path-returning call (directly, or through ONE .map(f => path-…) + // hop). The receiver is unwrapped through String() and one non-normalizer + // method chain, and may itself be a .map(...) whose callback returns a path + // call — the exact #2728 shape (arr.map(f => path.relative(R, f)).includes(x)). + const MEMBERSHIP_METHODS = new Set([ + 'includes', + 'indexOf', + 'startsWith', + 'endsWith', + ]); + + // True when `receiverNode` is (or traces to) a path-returning call, respecting + // POSIX-normalizer suppression. Traces: String() cast → non-normalizer method + // chain peel → .map(callback-returning-a-path-call) hops (recursively — nested + // .map() is also traced, since each recursion descends into a smaller subtree). + function receiverIsPathReturning(receiverNode) { + if (!receiverNode) return false; + // Suppressed if the receiver is already a valid POSIX normalizer. + if (isPosixNormalizerCall(receiverNode)) return false; + + let probe = unwrapString(receiverNode); + if (isPathReturningCall(probe)) return true; + + // Peel one non-normalizer method chain (.replace / .replaceAll / .split().join()). + const peeled = unwrapNonNormalizerMethodChain(receiverNode); + if (peeled != null) { + const inner = unwrapString(peeled); + if (isPathReturningCall(inner)) return true; + } + + // ONE .map(callback) hop: arr.map(f => path.relative(...)) — the callback's + // return expression is the path-returner. Arrow with a body, or arrow with a + // single-expression concisereturn, or a `function` with a single return. + if ( + receiverNode.type === 'CallExpression' && + receiverNode.callee.type === 'MemberExpression' && + !receiverNode.callee.computed && + receiverNode.callee.property.type === 'Identifier' && + receiverNode.callee.property.name === 'map' && + receiverNode.arguments.length >= 1 + ) { + const cb = receiverNode.arguments[0]; + const retExpr = mapCallbackReturnExpression(cb); + if (retExpr && receiverIsPathReturning(retExpr)) return true; + } + return false; + } + + // Extracts the returned expression from a .map() callback (arrow concise body, + // arrow block with one return, or function with one return). Null otherwise. + function mapCallbackReturnExpression(cb) { + if (!cb) return null; + if (cb.type === 'ArrowFunctionExpression') { + if (cb.body && cb.body.type !== 'BlockStatement') return cb.body; // concise + const stmts = cb.body && cb.body.body ? cb.body.body : []; + const ret = stmts.find((s) => s.type === 'ReturnStatement'); + if (ret && stmts.filter((s) => s.type === 'ReturnStatement').length === 1) { + return ret.argument; + } + } else if (cb.type === 'FunctionExpression') { + const stmts = cb.body && cb.body.body ? cb.body.body : []; + const ret = stmts.find((s) => s.type === 'ReturnStatement'); + if (ret && stmts.filter((s) => s.type === 'ReturnStatement').length === 1) { + return ret.argument; + } + } + return null; + } + /** * Returns true when `pathNode` represents a path call and `literalNode` is * a POSIX slash literal, AND the path call is NOT already normalized. @@ -182,6 +261,40 @@ const rule = { } return; } + + // ── #2764: .() ── + // .includes / .indexOf / .startsWith / .endsWith (and .match(/slashes/)) + // over a receiver that traces to a path-returning call (directly or via + // one .map() hop). The rule runs on tests/**/*.test.cjs only, so every such + // call is a test assertion shape that fails on Windows. + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + (MEMBERSHIP_METHODS.has(callee.property.name) || + callee.property.name === 'match') + ) { + const receiver = callee.object; + if (!receiverIsPathReturning(receiver)) return; + // The argument must be a POSIX-slash string literal (or, for .match, a + // regex literal whose pattern contains a slash). + const arg = node.arguments[0]; + const argIsPosix = + arg && isPosixSlashStringLiteral(arg); + const argIsSlashRegex = + callee.property.name === 'match' && + arg && + arg.type === 'Literal' && + arg.regex && + typeof arg.regex.pattern === 'string' && + arg.regex.pattern.includes('/'); + if (argIsPosix || argIsSlashRegex) { + if (!isWindowsExcludedNode(node, sourceCode)) { + context.report({ node, messageId: 'pathLiteral' }); + } + } + return; + } }, }; }, diff --git a/tests/no-path-literal-in-assert.rule.test.cjs b/tests/no-path-literal-in-assert.rule.test.cjs index 96490197c..c6e9bd548 100644 --- a/tests/no-path-literal-in-assert.rule.test.cjs +++ b/tests/no-path-literal-in-assert.rule.test.cjs @@ -179,6 +179,87 @@ describe('no-path-literal-in-assert invalid cases', () => { ], }); }); + + // ── #2764: membership / substring checks over a path-returning receiver ── + // The equality-only visitor missed these; they pass lint and fail on Windows. + + test('invalid (#2764): assert.ok(path.relative(R,f).includes("a/b"))', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.ok(path.relative(ROOT, f).includes('a/b'));`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid (#2764): .map(f => path.relative(...)) hop then .includes (the #2728 shape)', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.ok(arr.map(f => path.relative(ROOT, f)).includes('a/b'));`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid (#2764): path.join(...).indexOf("a/b") !== -1', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.ok(path.join(a, b).indexOf('a/b') !== -1);`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid (#2764): path.resolve(...).startsWith("a/b")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.ok(path.resolve(x).startsWith('a/b'));`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid (#2764): path.resolve(...).endsWith("a/b")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.ok(path.resolve(x).endsWith('a/b'));`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); + + test('invalid (#2764): path.resolve(...).match(/a\\/b/) — regex with a slash', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [], + invalid: [ + { + code: `assert.ok(path.resolve(x).match(/a\\/b/));`, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'pathLiteral' }], + }, + ], + }); + }); }); // ─── VALID cases (no violation expected) ───────────────────────────────────── @@ -337,6 +418,68 @@ describe('no-path-literal-in-assert valid cases', () => { invalid: [], }); }); + + // ── #2764: membership checks that must NOT trigger ── + + test('valid (#2764): normalized receiver .includes("a/b") — POSIX normalizer suppresses', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.ok(path.relative(ROOT, f).replace(/\\\\/g, '/').includes('a/b'));`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid (#2764): non-path string receiver .includes("a/b")', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.ok('plain string'.includes('a/b'));`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid (#2764): path receiver .includes(arg-with-no-slash)', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.ok(path.relative(ROOT, f).includes('foo'));`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid (#2764): path receiver .match(regex-with-no-slash)', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `assert.ok(path.resolve(x).match(/^home/));`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid (#2764): membership inside a Windows-excluded platform guard', () => { + ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, { + valid: [ + { + code: `if (process.platform !== 'win32') { assert.ok(path.relative(ROOT, f).includes('a/b')); }`, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); }); // ─── C1: isPosixNormalizerCall must require backslash-targeting regex ─────────