fix(#2764): extend no-path-literal-in-assert to membership/substring checks (catches the #2728 Windows CI shape) (#2879)
* test(#2764): no-path-literal-in-assert must flag membership/substring checks over path-returners The equality-only visitor missed .includes/.indexOf/.startsWith/.endsWith over a path-returning receiver (directly or via a .map() hop) — these pass lint and fail on Windows. Add RuleTester invalid cases for each shape + valid cases for the suppressions (POSIX-normalized receiver, non-path receiver, no-slash arg). * fix(#2764): extend no-path-literal-in-assert to membership/substring checks over path-returners Add a third CallExpression shape: .includes/.indexOf/.startsWith/.endsWith/.match over a receiver that traces to a path-returning call (directly, or through ONE .map(f => path-…) hop — the #2728 shape that passed lint and failed Windows CI). Reuse isPathReturningCall/isPosixSlashStringLiteral/isPosixNormalizerCall; respect the normalizer + Windows-excluded suppressions. The rule is scoped to tests/**/*.test.cjs. * chore(#2764): changeset fragment * test+docs(#2764): add .match test coverage, Windows-excluded membership case, fix doc accuracy (review findings) - MAJOR: .match was supported but untested — add invalid (.match with slash) + valid (.match no-slash) RuleTester cases. - MINOR: Windows-excluded membership case was claimed by the matrix but absent — add a platform-guard valid case in membership form. - MINOR: .map() hop comment said 'ONE' but recursion allows nesting — reword to 'recursively'. - Add known-boundary (d) note for the membership/.match shape. - Fix a premature comment-close from a literal */ in the glob example. * chore(#2764): backfill changeset PR number (2879) --------- Co-authored-by: Test <test@example.com>
This commit is contained in:
5
.changeset/sturdy-deer-greet.md
Normal file
5
.changeset/sturdy-deer-greet.md
Normal file
@@ -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)
|
||||
@@ -44,6 +44,14 @@
|
||||
* path really does contain forward-slashes even on Windows), wrapping with
|
||||
* `String(<x>).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).<matcher>(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: <path-returning-expr>.<membership-method>(<posix-literal>) ──
|
||||
// .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;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 ─────────
|
||||
|
||||
Reference in New Issue
Block a user