@sister.software/oxlint-config 9.3.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file A prefer-length-truthiness rule, authored as an oxlint JS plugin (ESLint v9-compatible API).
6
+ * It is the house counterpart to `unicorn/explicit-length-check`, which enforces the opposite
7
+ * convention and is therefore off: `if (items.length)` reads better here than
8
+ * `if (items.length > 0)`.
9
+ *
10
+ * The rule only fires where the value is ALREADY coerced to a boolean — a condition, a ternary
11
+ * test, or the operand of `!` — including through `&&`/`||` nested inside one. Outside those
12
+ * positions the comparison is the value itself, and rewriting `const hasItems = items.length > 0`
13
+ * would silently change its type from boolean to number.
14
+ */
15
+
16
+ import type { AstNode, Fixer, Rule, RuleContext } from "./plugin-types.js"
17
+
18
+ /** Comparisons meaning "non-empty", which become the bare length. */
19
+ const TRUTHY_FORMS = new Set(["> 0", "!== 0", "!= 0", ">= 1"])
20
+
21
+ /** Comparisons meaning "empty", which become a negated length. */
22
+ const FALSY_FORMS = new Set(["=== 0", "== 0", "< 1"])
23
+
24
+ /** Members whose length-ness the rule understands. */
25
+ const LENGTH_PROPERTIES = new Set(["length", "size"])
26
+
27
+ /** The comparison rendered as `<operator> <literal>`, or null when it is not a length comparison. */
28
+ function classify(node: AstNode): { member: AstNode; negate: boolean } | null {
29
+ if (node.type !== "BinaryExpression" || !node.operator || !node.left || !node.right) return null
30
+
31
+ // Accept both `x.length > 0` and the flipped `0 < x.length`.
32
+ const flipped: Record<string, string> = { "<": ">", ">": "<", "<=": ">=", ">=": "<=" }
33
+ let { left, right, operator } = { left: node.left, right: node.right, operator: node.operator }
34
+
35
+ if (left.type === "Literal" || left.type === "NumericLiteral") {
36
+ ;[left, right] = [right, left]
37
+ operator = flipped[operator] ?? operator
38
+ }
39
+
40
+ if (left.type !== "MemberExpression" && left.type !== "StaticMemberExpression") return null
41
+
42
+ const property = (left as AstNode & { property?: AstNode }).property
43
+
44
+ if (!property || property.type !== "Identifier" || !LENGTH_PROPERTIES.has(property.name ?? "")) return null
45
+
46
+ if (right.type !== "Literal" && right.type !== "NumericLiteral") return null
47
+
48
+ if (typeof right.value !== "number") return null
49
+
50
+ const form = `${operator} ${right.value}`
51
+
52
+ if (TRUTHY_FORMS.has(form)) return { member: left, negate: false }
53
+
54
+ if (FALSY_FORMS.has(form)) return { member: left, negate: true }
55
+
56
+ return null
57
+ }
58
+
59
+ export const preferLengthTruthinessRule: Rule = {
60
+ meta: {
61
+ name: "prefer-length-truthiness",
62
+ type: "suggestion",
63
+ fixable: "code",
64
+ schema: [{ type: "object", additionalProperties: true }],
65
+ },
66
+ create(context: RuleContext) {
67
+ const sourceCode = context.sourceCode ?? context.getSourceCode!()
68
+ const text = sourceCode.getText()
69
+
70
+ function report(node: AstNode) {
71
+ const hit = classify(node)
72
+
73
+ if (!hit) return
74
+
75
+ const member = text.slice(hit.member.range[0], hit.member.range[1])
76
+ const replacement = hit.negate ? `!${member}` : member
77
+
78
+ context.report({
79
+ node,
80
+ message: `Prefer \`${replacement}\` over an explicit length comparison — the house convention is truthiness.`,
81
+ fix(fixer: Fixer) {
82
+ return fixer.replaceTextRange(node.range, replacement)
83
+ },
84
+ })
85
+ }
86
+
87
+ /** Walk into a boolean context: logical operands and `!` arguments stay boolean. */
88
+ function visitCondition(node: AstNode | null | undefined) {
89
+ if (!node) return
90
+
91
+ if (node.type === "LogicalExpression") {
92
+ visitCondition(node.left)
93
+ visitCondition(node.right)
94
+
95
+ return
96
+ }
97
+
98
+ if (node.type === "UnaryExpression" && node.operator === "!") {
99
+ visitCondition(node.argument)
100
+
101
+ return
102
+ }
103
+
104
+ report(node)
105
+ }
106
+
107
+ return {
108
+ IfStatement(node) {
109
+ visitCondition(node.test)
110
+ },
111
+ WhileStatement(node) {
112
+ visitCondition(node.test)
113
+ },
114
+ DoWhileStatement(node) {
115
+ visitCondition(node.test)
116
+ },
117
+ ForStatement(node) {
118
+ visitCondition(node.test)
119
+ },
120
+ ConditionalExpression(node) {
121
+ visitCondition(node.test)
122
+ },
123
+ UnaryExpression(node) {
124
+ if (node.operator === "!") {
125
+ visitCondition(node.argument)
126
+ }
127
+ },
128
+ }
129
+ },
130
+ }
131
+
132
+ export default preferLengthTruthinessRule
@@ -22,6 +22,29 @@ export interface AstNode {
22
22
  /** `if`/`else` branches, for the require-braces rule. */
23
23
  consequent?: AstNode
24
24
  alternate?: AstNode | null
25
+ /** Binary/unary operator text, for the threshold rule. */
26
+ operator?: string
27
+ /** Binary-expression operands. */
28
+ left?: AstNode
29
+ right?: AstNode
30
+ /** Unary-expression operand. */
31
+ argument?: AstNode
32
+ /** A literal's value, and its verbatim source text (`raw` preserves a `0x` prefix). */
33
+ value?: unknown
34
+ raw?: string
35
+ /** `const` / `let` / `var`, for the constant-doc rule. */
36
+ kind?: string
37
+ /** Declarators of a variable declaration. */
38
+ declarations?: AstNode[]
39
+ /** A declarator's binding identifier and initializer. */
40
+ id?: AstNode
41
+ init?: AstNode | null
42
+ /** An identifier's name. */
43
+ name?: string
44
+ /** The condition of an `if`/`while`/`for`/ternary, for the length-truthiness rule. */
45
+ test?: AstNode | null
46
+ /** A member expression's accessed property. */
47
+ property?: AstNode
25
48
  }
26
49
 
27
50
  export interface SourceCode {
package/src/plugin.ts CHANGED
@@ -7,10 +7,13 @@
7
7
  */
8
8
 
9
9
  import { bracesRule } from "./braces-plugin.js"
10
+ import { requireConstantDocRule } from "./constant-doc-plugin.js"
10
11
  import { headerRule } from "./headers-plugin.js"
12
+ import { preferLengthTruthinessRule } from "./length-truthiness-plugin.js"
11
13
  import { paddingRule } from "./padding-plugin.js"
12
14
  import type { Plugin } from "./plugin-types.js"
13
15
  import { noProcessGlobalsRule } from "./process-globals-plugin.js"
16
+ import { noUnnamedThresholdRule } from "./threshold-plugin.js"
14
17
 
15
18
  const sisterSoftwarePlugin: Plugin = {
16
19
  meta: { name: "sister-software" },
@@ -19,6 +22,9 @@ const sisterSoftwarePlugin: Plugin = {
19
22
  "padding-lines": paddingRule,
20
23
  "require-braces": bracesRule,
21
24
  "no-process-globals": noProcessGlobalsRule,
25
+ "no-unnamed-threshold": noUnnamedThresholdRule,
26
+ "require-constant-doc": requireConstantDocRule,
27
+ "prefer-length-truthiness": preferLengthTruthinessRule,
22
28
  },
23
29
  }
24
30
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file A no-unnamed-threshold rule, authored as an oxlint JS plugin (ESLint v9-compatible API). It
6
+ * flags a numeric literal used as a threshold — an operand of a comparison — and nothing else.
7
+ * Numbers inside array and object literals are left alone, because a bounding box or a codepoint
8
+ * table is data rather than a tuning knob. That distinction is why this exists instead of
9
+ * `no-magic-numbers`, which cannot express it.
10
+ */
11
+
12
+ import type { AstNode, Rule } from "./plugin-types.js"
13
+
14
+ /** Operators whose operands read as thresholds. */
15
+ const COMPARISON_OPERATORS = new Set(["<", ">", "<=", ">=", "===", "!==", "==", "!="])
16
+
17
+ /** Numbers so conventional that naming them costs more clarity than it buys. */
18
+ const DEFAULT_IGNORE = [-1, 0, 1, 2, 0.5, 10, 100, 1000]
19
+
20
+ /** Radix-prefixed literals: `0x1f`, `0b1010`, `0o777`. */
21
+ const RADIX_PREFIXED = /^0[xXbBoO]/
22
+
23
+ /** Options accepted by {@link noUnnamedThresholdRule}. */
24
+ export interface ThresholdOptions {
25
+ /** Values that may appear unnamed. Replaces the default list rather than extending it. */
26
+ ignore?: number[]
27
+ /** Exempt radix-prefixed literals, which already read as codepoints or bit masks. */
28
+ allowHex?: boolean
29
+ }
30
+
31
+ export const noUnnamedThresholdRule: Rule = {
32
+ meta: {
33
+ name: "no-unnamed-threshold",
34
+ type: "suggestion",
35
+ schema: [{ type: "object", additionalProperties: true }],
36
+ },
37
+ create(context) {
38
+ const options = (context.options[0] ?? {}) as ThresholdOptions
39
+ const ignore = new Set(options.ignore ?? DEFAULT_IGNORE)
40
+ const allowHex = options.allowHex ?? true
41
+
42
+ function check(operand: AstNode | undefined) {
43
+ if (!operand) return
44
+
45
+ let literal = operand
46
+ let sign = 1
47
+
48
+ // `x < -273.15` parses as a unary minus wrapping the literal.
49
+ if (operand.type === "UnaryExpression") {
50
+ if (operand.operator !== "-" && operand.operator !== "+") return
51
+
52
+ if (!operand.argument) return
53
+
54
+ sign = operand.operator === "-" ? -1 : 1
55
+ literal = operand.argument
56
+ }
57
+
58
+ // oxlint's AST uses ESTree `Literal` in some positions and Babel-style `NumericLiteral` in
59
+ // others, so both are accepted.
60
+ if (literal.type !== "Literal" && literal.type !== "NumericLiteral") return
61
+
62
+ if (typeof literal.value !== "number") return
63
+
64
+ const raw = literal.raw ?? String(literal.value)
65
+
66
+ // `cp >= 0x3040` already reads as a codepoint boundary; a name adds nothing.
67
+ if (allowHex && RADIX_PREFIXED.test(raw)) return
68
+
69
+ if (ignore.has(sign * literal.value)) return
70
+
71
+ // `raw` is the literal's own text, so a negated value reads as `273.15` without this.
72
+ const shown = sign === -1 ? `-${raw}` : raw
73
+
74
+ context.report({
75
+ node: literal,
76
+ message:
77
+ `Unnamed threshold \`${shown}\` — extract it to a documented named constant so a reader can tell ` +
78
+ `what it means and where the value came from.`,
79
+ })
80
+ }
81
+
82
+ return {
83
+ BinaryExpression(node) {
84
+ if (!node.operator || !COMPARISON_OPERATORS.has(node.operator)) return
85
+
86
+ check(node.left)
87
+ check(node.right)
88
+ },
89
+ }
90
+ },
91
+ }
92
+
93
+ export default noUnnamedThresholdRule