|
| 1 | +// Boolean assertions like `assert(value)` and `assert.ok(value)` produce a useless failure message |
| 2 | +// ("Expected true, got false") when no second-argument message is provided. That makes debugging |
| 3 | +// flaky tests painful: you know the expression was falsy, but not what value it actually had. |
| 4 | +// |
| 5 | +// This rule requires a message argument whenever the first argument is "non-trivial" — i.e. an |
| 6 | +// expression whose failure mode would not, on its own, tell you what was being asserted on. |
| 7 | +// Trivial (allowed without a message) shapes are those whose source line is fully self-describing: |
| 8 | +// |
| 9 | +// - Literals and references: `true`, `'foo'`, `isReady`, `obj.prop.sub`, `arr[0]`, `obj?.prop` |
| 10 | +// - Logical/structural unary ops on a trivial operand: `!x`, `!!x`, `typeof x`, `delete obj.k` |
| 11 | +// - `in` / `instanceof` with trivial operands: `'foo' in carrier`, `err instanceof Error` |
| 12 | +// - Calls where the callee and all arguments are trivial: `arr.includes('foo')`, |
| 13 | +// `Array.isArray(x)`, `carrier.hasOwnProperty('x-datadog-trace-id')` |
| 14 | +// - Zero-argument method calls in a chain (callee is a MemberExpression): `span.context()`, |
| 15 | +// `arr.entries()`. These act as getter-style navigation and are treated as part of a path. |
| 16 | +// |
| 17 | +// Non-trivial shapes (require a message) include value comparisons (`x > 5`, `x === 'foo'`), |
| 18 | +// logical combinations (`x && y`), dynamic indexing (`arr[i]`), zero-argument plain function |
| 19 | +// calls (`getResult()`), and anything whose actual value would be useful for debugging the |
| 20 | +// failure. |
| 21 | + |
| 22 | +/** @typedef {import('estree').Node} Node */ |
| 23 | +/** @typedef {import('estree').CallExpression} CallExpression */ |
| 24 | + |
| 25 | +const ASSERT_CALL_NAMES = ['assert', 'assert.ok'] |
| 26 | + |
| 27 | +// Unary operators that don't hide a "value of interest": they just transform a reference into a |
| 28 | +// boolean/type/undefined. `+`, `-`, `~` are excluded — those hide numeric value differences. |
| 29 | +const TRIVIAL_UNARY_OPERATORS = new Set(['!', 'typeof', 'void', 'delete']) |
| 30 | + |
| 31 | +// Binary operators that ask a structural yes/no question with both operands inspectable from |
| 32 | +// source. Excludes value comparisons (`===`, `==`, `<`, `>`, etc.) which hide the actual value. |
| 33 | +const TRIVIAL_BINARY_OPERATORS = new Set(['in', 'instanceof']) |
| 34 | + |
| 35 | +export default { |
| 36 | + meta: { |
| 37 | + type: 'problem', |
| 38 | + docs: { |
| 39 | + description: |
| 40 | + 'Require a message argument on boolean assertions (`assert(value)` / `assert.ok(value)`) ' + |
| 41 | + 'whose first argument is a non-trivial expression, so failure messages reveal what was asserted.', |
| 42 | + recommended: true, |
| 43 | + }, |
| 44 | + schema: [], |
| 45 | + messages: { |
| 46 | + missingMessage: |
| 47 | + '`{{name}}(...)` with a non-trivial first argument should pass a descriptive message as the ' + |
| 48 | + 'second argument. Without it, failures only report "Expected true, got false" without any ' + |
| 49 | + 'context about the actual value. Include the runtime value in the message to make failures ' + |
| 50 | + 'debuggable.', |
| 51 | + }, |
| 52 | + }, |
| 53 | + |
| 54 | + create (context) { |
| 55 | + return { |
| 56 | + CallExpression (node) { |
| 57 | + const calleeName = getMatchedAssertName(node.callee) |
| 58 | + if (calleeName === undefined) return |
| 59 | + |
| 60 | + if (node.arguments.length === 0) return |
| 61 | + |
| 62 | + const firstArg = node.arguments[0] |
| 63 | + |
| 64 | + if (firstArg.type === 'SpreadElement') return |
| 65 | + |
| 66 | + if (node.arguments.length >= 2) return |
| 67 | + |
| 68 | + if (isTrivialExpression(firstArg)) return |
| 69 | + |
| 70 | + context.report({ |
| 71 | + node, |
| 72 | + messageId: 'missingMessage', |
| 73 | + data: { name: calleeName }, |
| 74 | + }) |
| 75 | + }, |
| 76 | + } |
| 77 | + }, |
| 78 | +} |
| 79 | + |
| 80 | +/** |
| 81 | + * @param {Node} callee |
| 82 | + * @returns {string | undefined} |
| 83 | + */ |
| 84 | +function getMatchedAssertName (callee) { |
| 85 | + for (const name of ASSERT_CALL_NAMES) { |
| 86 | + const parts = name.split('.') |
| 87 | + |
| 88 | + if (parts.length === 1) { |
| 89 | + if (callee.type === 'Identifier' && callee.name === parts[0]) { |
| 90 | + return name |
| 91 | + } |
| 92 | + } else if ( |
| 93 | + callee.type === 'MemberExpression' && |
| 94 | + !callee.computed && |
| 95 | + !callee.optional && |
| 96 | + callee.object.type === 'Identifier' && |
| 97 | + callee.object.name === parts[0] && |
| 98 | + callee.property.type === 'Identifier' && |
| 99 | + callee.property.name === parts[1] |
| 100 | + ) { |
| 101 | + return name |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + return undefined |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * A "trivial" expression is one whose source text already describes what is being asserted on, |
| 110 | + * so a failure of "Expected true, got false" is informative enough on its own. See the file |
| 111 | + * header for the full taxonomy. |
| 112 | + * |
| 113 | + * @param {Node} node |
| 114 | + * @returns {boolean} |
| 115 | + */ |
| 116 | +function isTrivialExpression (node) { |
| 117 | + if (node.type === 'ChainExpression') { |
| 118 | + return isTrivialExpression(node.expression) |
| 119 | + } |
| 120 | + |
| 121 | + if ( |
| 122 | + node.type === 'Literal' || |
| 123 | + node.type === 'Identifier' || |
| 124 | + node.type === 'ThisExpression' || |
| 125 | + node.type === 'Super' |
| 126 | + ) { |
| 127 | + return true |
| 128 | + } |
| 129 | + |
| 130 | + if (node.type === 'MemberExpression') { |
| 131 | + if (node.computed) { |
| 132 | + if (node.property.type !== 'Literal') return false |
| 133 | + } else if (node.property.type !== 'Identifier') { |
| 134 | + return false |
| 135 | + } |
| 136 | + |
| 137 | + return isTrivialExpression(node.object) |
| 138 | + } |
| 139 | + |
| 140 | + if (node.type === 'UnaryExpression') { |
| 141 | + return TRIVIAL_UNARY_OPERATORS.has(node.operator) && isTrivialExpression(node.argument) |
| 142 | + } |
| 143 | + |
| 144 | + if (node.type === 'BinaryExpression') { |
| 145 | + return TRIVIAL_BINARY_OPERATORS.has(node.operator) && |
| 146 | + isTrivialExpression(node.left) && |
| 147 | + isTrivialExpression(node.right) |
| 148 | + } |
| 149 | + |
| 150 | + if (node.type === 'CallExpression') { |
| 151 | + // A zero-arg plain function call (`getResult()`) is opaque — there's nothing in the source |
| 152 | + // line that hints at the returned value. But a zero-arg call whose callee is a member access |
| 153 | + // (`span.context()`, `arr.entries()`) typically acts as getter-style navigation in a chain |
| 154 | + // and is treated as part of the path. |
| 155 | + if (node.arguments.length === 0) { |
| 156 | + return getCalleeBody(node.callee).type === 'MemberExpression' && isTrivialExpression(node.callee) |
| 157 | + } |
| 158 | + |
| 159 | + for (const arg of node.arguments) { |
| 160 | + if (arg.type === 'SpreadElement') return false |
| 161 | + if (!isTrivialExpression(arg)) return false |
| 162 | + } |
| 163 | + |
| 164 | + return isTrivialExpression(node.callee) |
| 165 | + } |
| 166 | + |
| 167 | + return false |
| 168 | +} |
| 169 | + |
| 170 | +/** |
| 171 | + * Strips a leading optional-chain wrapper to expose the underlying callee, so `foo?.bar` is seen |
| 172 | + * as a MemberExpression (not a ChainExpression) when we're classifying a `CallExpression`. |
| 173 | + * |
| 174 | + * @param {Node} callee |
| 175 | + * @returns {Node} |
| 176 | + */ |
| 177 | +function getCalleeBody (callee) { |
| 178 | + return callee.type === 'ChainExpression' ? callee.expression : callee |
| 179 | +} |
0 commit comments