Skip to content

Commit fcffd44

Browse files
committed
chore(sync): cascade conditional-files gate + sort-equality-disjunctions rule from socket-repo-template
1 parent f2ce20e commit fcffd44

4 files changed

Lines changed: 254 additions & 1 deletion

File tree

.config/oxlint-plugin/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import preferNodeBuiltinImports from './rules/prefer-node-builtin-imports.js'
3131
import preferSafeDelete from './rules/prefer-safe-delete.js'
3232
import preferUndefinedOverNull from './rules/prefer-undefined-over-null.js'
3333
import socketApiTokenEnv from './rules/socket-api-token-env.js'
34+
import sortEqualityDisjunctions from './rules/sort-equality-disjunctions.js'
3435
import sortNamedImports from './rules/sort-named-imports.js'
3536
import sortRegexAlternations from './rules/sort-regex-alternations.js'
3637
import sortSetArgs from './rules/sort-set-args.js'
@@ -61,6 +62,7 @@ const plugin = {
6162
'prefer-safe-delete': preferSafeDelete,
6263
'prefer-undefined-over-null': preferUndefinedOverNull,
6364
'socket-api-token-env': socketApiTokenEnv,
65+
'sort-equality-disjunctions': sortEqualityDisjunctions,
6466
'sort-named-imports': sortNamedImports,
6567
'sort-regex-alternations': sortRegexAlternations,
6668
'sort-set-args': sortSetArgs,
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ Never silently let drift sit. Either reconcile in the same PR or open a follow-u
209209
- **Edits** — Edit tool, never `sed` / `awk`.
210210
- **Generated reports** — quality scans, security audits, perf snapshots, anything an automated tool emits — write to `.claude/reports/` (naturally gitignored as part of `.claude/*`, no separate rule needed). Never commit reports to a tracked `reports/`, `docs/reports/`, or similarly-named tracked directory: dated reports rot the moment they land and the directory becomes a graveyard. The current state of the repo is the report; tools regenerate findings on demand. If a finding is genuinely worth keeping past one run, fix it or open an issue — don't pickle it as a markdown file.
211211
- **Inclusive language** — see [`docs/claude.md/inclusive-language.md`](docs/claude.md/inclusive-language.md) for the substitution table.
212-
- **Sorting** — sort alphanumerically (literal byte order, ASCII before letters). Applies to: object property keys (config + return shapes + internal state — `__proto__: null` first); named imports inside a single statement (`import { a, b, c }`); `Set` / `SafeSet` constructor arguments; allowlists / denylists / config arrays / interface members. Position-bearing arrays (where index matters) keep their meaningful order. Full details in [`docs/claude.md/sorting.md`](docs/claude.md/sorting.md). When in doubt, sort.
212+
- **Sorting** — sort alphanumerically (literal byte order, ASCII before letters). Applies to: object property keys (config + return shapes + internal state — `__proto__: null` first); named imports inside a single statement (`import { a, b, c }`); `Set` / `SafeSet` constructor arguments; allowlists / denylists / config arrays / interface members; **string-equality disjunctions** (`x === 'a' || x === 'b'` and the De Morgan dual `x !== 'a' && x !== 'b'`). Position-bearing arrays (where index matters) keep their meaningful order. Full details in [`docs/claude.md/sorting.md`](docs/claude.md/sorting.md). When in doubt, sort.
213213
- **`Promise.race` / `Promise.any` in loops** — never re-race a pool that survives across iterations (the handlers stack). See `.claude/skills/plug-leaking-promise-race/SKILL.md`.
214214
- **`Safe` suffix** — non-throwing wrappers end in `Safe` (`safeDelete`, `safeDeleteSync`, `applySafe`, `weakRefSafe`). Read it as "X, but safe from throwing." The wrapper traps the thrown value internally and returns `undefined` (or the documented fallback). Don't invent alternative suffixes (`Try`, `OrUndefined`, `Maybe`) — pick `Safe`.
215215
- **`node:smol-*` modules** — feature-detect, then require. From outside socket-btm (socket-lib, socket-cli, anywhere else): `import { isBuiltin } from 'node:module'; if (isBuiltin('node:smol-X')) { const mod = require('node:smol-X') }`. The `node:smol-*` namespace is provided by socket-btm's smol Node binary; on stock Node `isBuiltin` returns false and the require would throw. Wrap the loader in a `/*@__NO_SIDE_EFFECTS__*/` lazy-load that caches the result — see `socket-lib/src/smol/util.ts` and `socket-lib/src/smol/primordial.ts` for canonical shape. **Inside** socket-btm's `additions/source-patched/` JS (the smol binary's own bootstrap code), use `internalBinding('smol_X')` directly — that's the C++-binding access path and it's guaranteed available there.

docs/claude.md/sorting.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Sort lists alphanumerically (literal byte order, ASCII before letters).
1111
- **Array literals** — when the array is a config list, allowlist, or set-like collection. Position-bearing arrays (e.g. `argv`, anything where index matters semantically) keep their meaningful order.
1212
- **`Set` constructor arguments**`new Set([...])` and `new SafeSet([...])` literals. The runtime is order-insensitive, so source order is alphanumeric. Same rationale as Array literals: predictable diffs, no merge conflicts on insertions.
1313
- **Regex alternation groups**`(foo|bar|baz)` reads as `(bar|baz|foo)`. Capturing, non-capturing, and named-capture groups all follow the rule. Auto-fixable when every alternative is a simple literal. The exception is order-bearing alternations where the regex engine MUST try one alternative before another (rare; the canonical example is markup parsers where `<!--|-->` would silently mismatch if reordered) — append `// socket-hook: allow regex-alternation-order` on those lines.
14+
- **String-equality disjunctions**`x === 'a' || x === 'b' || x === 'c'` reads with the comparand strings in alpha order. The De Morgan dual `x !== 'a' && x !== 'b'` (negative-membership check) follows the same rule. The `||` chain short-circuits regardless of operand order; sorting reduces diff churn when adding new comparands and makes "is X in this set?" checks visually consistent. Auto-fixable when every clause has the same left operand and uses string-literal comparands. Mixed shape (different left, different operator, non-string right) is skipped — those are usually genuine ordering-sensitive predicates and the autofix would change semantics.
1415

1516
## Default
1617

0 commit comments

Comments
 (0)