|
| 1 | +/** |
| 2 | + * @fileoverview Sort string-equality disjunctions alphanumerically. |
| 3 | + * |
| 4 | + * Per CLAUDE.md "Sorting" rule, `x === 'a' || x === 'b' || x === 'c'` |
| 5 | + * is sorted by the comparand string (literal byte order, ASCII before |
| 6 | + * letters). Order doesn't affect runtime semantics — JS's `||` |
| 7 | + * short-circuits regardless of operand order — but keeps the diff |
| 8 | + * churn low when adding a new comparand and makes "is X in this set?" |
| 9 | + * checks visually consistent across the fleet. |
| 10 | + * |
| 11 | + * Detects: |
| 12 | + * - `(x === 'a' || x === 'b')` |
| 13 | + * - `(x !== 'a' && x !== 'b')` — De Morgan dual; ordering rule applies |
| 14 | + * - Chains of any length (≥2 operands). |
| 15 | + * |
| 16 | + * Each disjunction must: |
| 17 | + * - Use the SAME left operand (`x` in the example) for every clause. |
| 18 | + * - Use the SAME comparison operator (`===` for `||` chains, `!==` |
| 19 | + * for `&&` chains). |
| 20 | + * - Use string-literal right operands (number / boolean / template |
| 21 | + * literals are skipped — those rarely benefit from alpha order |
| 22 | + * and confuse the autofix). |
| 23 | + * |
| 24 | + * Autofix: rewrites the right-hand string literals in sorted order. |
| 25 | + * Skipped (reports without fix) when: |
| 26 | + * - Any clause's left operand differs (mixed identifier). |
| 27 | + * - Any clause's right operand isn't a plain string literal. |
| 28 | + * - Any clause uses a different operator from the first. |
| 29 | + * - Comments live between clauses (reordering through a comment |
| 30 | + * would break attribution). |
| 31 | + * |
| 32 | + * Why a separate rule from sort-named-imports / sort-set-args: |
| 33 | + * - The shape is structurally different (BinaryExpression chain |
| 34 | + * under LogicalExpression, not an ArrayExpression / ImportSpecifier). |
| 35 | + * - Catches the most common "is this one of these constants?" |
| 36 | + * pattern in dispatch code (e.g. switch-prelude guards, |
| 37 | + * fix-action category checks). A single rule keeps this normalized. |
| 38 | + */ |
| 39 | + |
| 40 | +/** @type {import('eslint').Rule.RuleModule} */ |
| 41 | +const rule = { |
| 42 | + meta: { |
| 43 | + type: 'suggestion', |
| 44 | + docs: { |
| 45 | + description: |
| 46 | + 'Sort string-equality disjunctions alphanumerically (`x === "a" || x === "b"`).', |
| 47 | + category: 'Stylistic Issues', |
| 48 | + recommended: true, |
| 49 | + }, |
| 50 | + fixable: 'code', |
| 51 | + messages: { |
| 52 | + unsorted: |
| 53 | + 'String-equality disjunction operands are out of alphabetical order. Saw `{{actual}}`, expected `{{expected}}`.', |
| 54 | + }, |
| 55 | + schema: [], |
| 56 | + }, |
| 57 | + |
| 58 | + create(context) { |
| 59 | + const sourceCode = context.getSourceCode |
| 60 | + ? context.getSourceCode() |
| 61 | + : context.sourceCode |
| 62 | + |
| 63 | + /** |
| 64 | + * Flatten a left-associative LogicalExpression chain into a list |
| 65 | + * of leaf nodes. `(a || b) || c` and `a || (b || c)` both flatten |
| 66 | + * to [a, b, c]. We require the chain operator to be uniform |
| 67 | + * (caller checks). |
| 68 | + */ |
| 69 | + function flatten(node, op, out) { |
| 70 | + if (node.type === 'LogicalExpression' && node.operator === op) { |
| 71 | + flatten(node.left, op, out) |
| 72 | + flatten(node.right, op, out) |
| 73 | + } else { |
| 74 | + out.push(node) |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + /** |
| 79 | + * For a binary-equality leaf, return `{ left, right, operator }` |
| 80 | + * if it's the shape we sort. Returns undefined otherwise. |
| 81 | + */ |
| 82 | + function asEqualityClause(node) { |
| 83 | + if (node.type !== 'BinaryExpression') { |
| 84 | + return undefined |
| 85 | + } |
| 86 | + if (node.operator !== '===' && node.operator !== '!==') { |
| 87 | + return undefined |
| 88 | + } |
| 89 | + // Right side must be a plain string-literal Identifier-comparand pattern. |
| 90 | + if ( |
| 91 | + node.right.type !== 'Literal' || |
| 92 | + typeof node.right.value !== 'string' |
| 93 | + ) { |
| 94 | + return undefined |
| 95 | + } |
| 96 | + // Left side: prefer Identifier, but accept MemberExpression so |
| 97 | + // `cat.x === 'a' || cat.x === 'b'` works too. |
| 98 | + if ( |
| 99 | + node.left.type !== 'Identifier' && |
| 100 | + node.left.type !== 'MemberExpression' |
| 101 | + ) { |
| 102 | + return undefined |
| 103 | + } |
| 104 | + return { |
| 105 | + leftText: sourceCode.getText(node.left), |
| 106 | + operator: node.operator, |
| 107 | + right: node.right, |
| 108 | + rightValue: node.right.value, |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + /** |
| 113 | + * Returns true if a comment lies anywhere between the first and |
| 114 | + * last leaf of the chain. Comment-aware skipping prevents the |
| 115 | + * autofix from silently relocating attribution. |
| 116 | + */ |
| 117 | + function hasInteriorComment(leaves) { |
| 118 | + if (!sourceCode.getCommentsInside) { |
| 119 | + return false |
| 120 | + } |
| 121 | + const first = leaves[0] |
| 122 | + const last = leaves[leaves.length - 1] |
| 123 | + const all = sourceCode.getCommentsInside({ |
| 124 | + range: [first.range[0], last.range[1]], |
| 125 | + loc: { start: first.loc.start, end: last.loc.end }, |
| 126 | + type: 'Program', |
| 127 | + }) |
| 128 | + return all.length > 0 |
| 129 | + } |
| 130 | + |
| 131 | + function checkChain(rootNode) { |
| 132 | + // Top-level filter: only check the OUTERMOST `||` or `&&` of a |
| 133 | + // chain, not its sub-expressions. We detect "outermost" by the |
| 134 | + // parent being either non-LogicalExpression or a different |
| 135 | + // operator. |
| 136 | + const parent = rootNode.parent |
| 137 | + if ( |
| 138 | + parent && |
| 139 | + parent.type === 'LogicalExpression' && |
| 140 | + parent.operator === rootNode.operator |
| 141 | + ) { |
| 142 | + return |
| 143 | + } |
| 144 | + |
| 145 | + const op = rootNode.operator |
| 146 | + // We only process || and && chains. |
| 147 | + if (op !== '||' && op !== '&&') { |
| 148 | + return |
| 149 | + } |
| 150 | + |
| 151 | + const leaves = [] |
| 152 | + flatten(rootNode, op, leaves) |
| 153 | + if (leaves.length < 2) { |
| 154 | + return |
| 155 | + } |
| 156 | + |
| 157 | + const clauses = [] |
| 158 | + for (const leaf of leaves) { |
| 159 | + const c = asEqualityClause(leaf) |
| 160 | + if (!c) { |
| 161 | + // Mixed shape — skip the whole chain. The rule only |
| 162 | + // applies to homogeneous equality chains. |
| 163 | + return |
| 164 | + } |
| 165 | + clauses.push(c) |
| 166 | + } |
| 167 | + |
| 168 | + // Operator/leftText must be uniform within the chain. For `||` |
| 169 | + // chains the natural shape is `===`; for `&&` chains it's `!==` |
| 170 | + // (De Morgan). Mixed → skip (rare and the rewrite would change |
| 171 | + // semantics). |
| 172 | + const firstLeft = clauses[0].leftText |
| 173 | + const firstOp = clauses[0].operator |
| 174 | + for (let i = 1; i < clauses.length; i++) { |
| 175 | + if ( |
| 176 | + clauses[i].leftText !== firstLeft || |
| 177 | + clauses[i].operator !== firstOp |
| 178 | + ) { |
| 179 | + return |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + // For `||` chains, expect `===`. For `&&` chains, expect `!==`. |
| 184 | + // Other combinations are valid logic but not the shape this rule |
| 185 | + // sorts (they'd be tautologies or contradictions). |
| 186 | + if (op === '||' && firstOp !== '===') { |
| 187 | + return |
| 188 | + } |
| 189 | + if (op === '&&' && firstOp !== '!==') { |
| 190 | + return |
| 191 | + } |
| 192 | + |
| 193 | + // Compute the sorted order. |
| 194 | + const sortedClauses = [...clauses].sort((a, b) => { |
| 195 | + if (a.rightValue < b.rightValue) { |
| 196 | + return -1 |
| 197 | + } |
| 198 | + if (a.rightValue > b.rightValue) { |
| 199 | + return 1 |
| 200 | + } |
| 201 | + return 0 |
| 202 | + }) |
| 203 | + |
| 204 | + const actualOrder = clauses.map(c => c.rightValue).join(', ') |
| 205 | + const expectedOrder = sortedClauses.map(c => c.rightValue).join(', ') |
| 206 | + |
| 207 | + if (actualOrder === expectedOrder) { |
| 208 | + return |
| 209 | + } |
| 210 | + |
| 211 | + // Check for interior comments — skip autofix if any. |
| 212 | + if (hasInteriorComment(leaves)) { |
| 213 | + context.report({ |
| 214 | + node: rootNode, |
| 215 | + messageId: 'unsorted', |
| 216 | + data: { actual: actualOrder, expected: expectedOrder }, |
| 217 | + }) |
| 218 | + return |
| 219 | + } |
| 220 | + |
| 221 | + context.report({ |
| 222 | + node: rootNode, |
| 223 | + messageId: 'unsorted', |
| 224 | + data: { actual: actualOrder, expected: expectedOrder }, |
| 225 | + fix(fixer) { |
| 226 | + // Replace each leaf's right-string-literal with the |
| 227 | + // sorted-position counterpart. Because the chain is |
| 228 | + // homogeneous (same left, same op), the rewrite is safe |
| 229 | + // semantically — only the comparand strings reorder. |
| 230 | + const fixes = [] |
| 231 | + for (let i = 0; i < leaves.length; i++) { |
| 232 | + const leaf = leaves[i] |
| 233 | + const targetRight = sortedClauses[i].right |
| 234 | + // The leaf's right node is what we rewrite. |
| 235 | + // BinaryExpression.right's range covers just the literal. |
| 236 | + const rawTarget = sourceCode.getText(targetRight) |
| 237 | + fixes.push(fixer.replaceText(asEqualityClause(leaf).right, rawTarget)) |
| 238 | + } |
| 239 | + return fixes |
| 240 | + }, |
| 241 | + }) |
| 242 | + } |
| 243 | + |
| 244 | + return { |
| 245 | + LogicalExpression: checkChain, |
| 246 | + } |
| 247 | + }, |
| 248 | +} |
| 249 | + |
| 250 | +export default rule |
0 commit comments