@naturalcycles/dev-lib 20.69.0 → 20.70.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.
- package/cfg/oxlint-plugin-nc.d.ts +10 -0
- package/cfg/oxlint-plugin-nc.js +148 -0
- package/cfg/oxlint.config.js +5 -1
- package/package.json +1 -1
- package/readme.md +5 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const ASSERTABLE_ERRORS = new Set(['Error', 'AppError', 'AssertionError'])
|
|
2
|
+
const EQUALITY_OPERATORS = new Set(['===', '!==', '==', '!='])
|
|
3
|
+
// A condition that stays readable once negated; `!(a instanceof B && c)` does not
|
|
4
|
+
const UNASSERTABLE_TESTS = new Set([
|
|
5
|
+
'LogicalExpression',
|
|
6
|
+
'ConditionalExpression',
|
|
7
|
+
'AssignmentExpression',
|
|
8
|
+
'SequenceExpression',
|
|
9
|
+
])
|
|
10
|
+
// An inline callback in the condition (`items.some(item => ...)`) does not read as an assertion once negated
|
|
11
|
+
const FUNCTION_NODES = new Set(['ArrowFunctionExpression', 'FunctionExpression'])
|
|
12
|
+
// `_assert` builds its message and data before checking, so a call, await or interpolated message there
|
|
13
|
+
// would run on the happy path too (keep ids in the data bag), and a value the condition narrows
|
|
14
|
+
// (`if (!res.ok) throw new Error(res.error)`) no longer typechecks
|
|
15
|
+
const EVALUATED_ARGUMENTS = new Set([
|
|
16
|
+
'CallExpression',
|
|
17
|
+
'NewExpression',
|
|
18
|
+
'AwaitExpression',
|
|
19
|
+
'TaggedTemplateExpression',
|
|
20
|
+
'YieldExpression',
|
|
21
|
+
'UpdateExpression',
|
|
22
|
+
'AssignmentExpression',
|
|
23
|
+
'SpreadElement',
|
|
24
|
+
])
|
|
25
|
+
// Test files and test helpers throw plain errors on purpose (mocks, fakes). Checked here rather than in
|
|
26
|
+
// a shared `overrides` entry, because `defineOxlintConfig` lets a consumer's `overrides` replace it
|
|
27
|
+
const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$|\/(?:test|testing)\//
|
|
28
|
+
|
|
29
|
+
// No `fix`/`suggest`: dev-lib runs oxlint with --fix-dangerously, which would apply them and leave
|
|
30
|
+
// `_assert` unimported
|
|
31
|
+
const preferAssertRule = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: 'suggestion',
|
|
34
|
+
docs: {
|
|
35
|
+
description:
|
|
36
|
+
'Prefer _assert over an if whose only statement throws an Error or AppError with a static message',
|
|
37
|
+
},
|
|
38
|
+
messages: {
|
|
39
|
+
preferAssert:
|
|
40
|
+
'Use _assert(condition, message, data) from @naturalcycles/js-lib/error instead of throwing in an if',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
create(context) {
|
|
44
|
+
if (TEST_FILE_PATTERN.test(context.filename)) return {}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
IfStatement(node) {
|
|
48
|
+
if (node.alternate || node.parent.type === 'IfStatement') return
|
|
49
|
+
if (!isAssertableTest(node.test)) return
|
|
50
|
+
|
|
51
|
+
const { consequent } = node
|
|
52
|
+
const statement =
|
|
53
|
+
consequent.type === 'BlockStatement' && consequent.body.length === 1
|
|
54
|
+
? consequent.body[0]
|
|
55
|
+
: consequent
|
|
56
|
+
if (statement?.type !== 'ThrowStatement') return
|
|
57
|
+
|
|
58
|
+
const error = statement.argument
|
|
59
|
+
if (
|
|
60
|
+
error.type !== 'NewExpression' ||
|
|
61
|
+
error.callee.type !== 'Identifier' ||
|
|
62
|
+
!ASSERTABLE_ERRORS.has(error.callee.name) ||
|
|
63
|
+
error.arguments.length > maxArguments(error.callee.name) ||
|
|
64
|
+
error.arguments.some(argument => containsEvaluation(argument))
|
|
65
|
+
) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const narrowed = referencedNames(node.test)
|
|
69
|
+
if (error.arguments.some(argument => referencesAny(argument, narrowed))) return
|
|
70
|
+
|
|
71
|
+
context.report({ node, messageId: 'preferAssert' })
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A second `Error` argument is `{ cause }`, which `_assert` would store as data and lose as a cause
|
|
78
|
+
function maxArguments(errorName) {
|
|
79
|
+
return errorName === 'Error' ? 1 : 2
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isAssertableTest(test) {
|
|
83
|
+
if (UNASSERTABLE_TESTS.has(test.type)) return false
|
|
84
|
+
if (test.type === 'BinaryExpression' && !EQUALITY_OPERATORS.has(test.operator)) return false
|
|
85
|
+
return !containsFunction(test)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function containsFunction(node) {
|
|
89
|
+
return visit(node, child => FUNCTION_NODES.has(child.type))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function containsEvaluation(node) {
|
|
93
|
+
return visit(
|
|
94
|
+
node,
|
|
95
|
+
child =>
|
|
96
|
+
EVALUATED_ARGUMENTS.has(child.type) ||
|
|
97
|
+
(child.type === 'TemplateLiteral' && child.expressions.length > 0),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function referencedNames(node) {
|
|
102
|
+
const names = new Set()
|
|
103
|
+
visit(node, child => {
|
|
104
|
+
if (child.type === 'Identifier') names.add(child.name)
|
|
105
|
+
if (child.type === 'ThisExpression') names.add('this')
|
|
106
|
+
return false
|
|
107
|
+
})
|
|
108
|
+
return names
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function referencesAny(node, names) {
|
|
112
|
+
return visit(node, child => {
|
|
113
|
+
if (child.type === 'Identifier') return names.has(child.name)
|
|
114
|
+
if (child.type === 'ThisExpression') return names.has('this')
|
|
115
|
+
return false
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Depth-first walk over the AST below `node`, skipping `parent` links and the names that are not
|
|
121
|
+
* references: non-computed member properties and object keys. Stops at the first node `fn` returns
|
|
122
|
+
* true for, and returns whether there was one.
|
|
123
|
+
*/
|
|
124
|
+
function visit(node, fn) {
|
|
125
|
+
if (!node || typeof node !== 'object') return false
|
|
126
|
+
if (Array.isArray(node)) return node.some(item => visit(item, fn))
|
|
127
|
+
const { type } = node
|
|
128
|
+
if (!type) return false
|
|
129
|
+
if (fn(node)) return true
|
|
130
|
+
for (const [key, child] of Object.entries(node)) {
|
|
131
|
+
if (key === 'parent' || key === 'loc' || key === 'range') continue
|
|
132
|
+
if (key === 'property' && type === 'MemberExpression' && !node.computed) continue
|
|
133
|
+
if (key === 'key' && type === 'Property' && !node.computed) continue
|
|
134
|
+
if (visit(child, fn)) return true
|
|
135
|
+
}
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ncPlugin = {
|
|
140
|
+
meta: {
|
|
141
|
+
name: 'nc',
|
|
142
|
+
},
|
|
143
|
+
rules: {
|
|
144
|
+
'prefer-assert': preferAssertRule,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default ncPlugin
|
package/cfg/oxlint.config.js
CHANGED
|
@@ -39,7 +39,10 @@ export const sharedConfig = {
|
|
|
39
39
|
// "vue",
|
|
40
40
|
'vitest',
|
|
41
41
|
],
|
|
42
|
-
jsPlugins: [
|
|
42
|
+
jsPlugins: [
|
|
43
|
+
`${import.meta.dirname}/oxlint-plugin-stylistic.js`,
|
|
44
|
+
`${import.meta.dirname}/oxlint-plugin-nc.js`,
|
|
45
|
+
],
|
|
43
46
|
options: {
|
|
44
47
|
reportUnusedDisableDirectives: 'deny',
|
|
45
48
|
respectEslintDisableDirectives: false,
|
|
@@ -69,6 +72,7 @@ export const sharedConfig = {
|
|
|
69
72
|
{ blankLine: 'always', prev: '*', next: 'class' },
|
|
70
73
|
],
|
|
71
74
|
'@stylistic/lines-between-class-members': [2, 'always', { exceptAfterSingleLine: true }],
|
|
75
|
+
'nc/prefer-assert': 0, // opt-in (set to 2): flags `if (cond) throw new Error(...)` that could be `_assert(...)`
|
|
72
76
|
complexity: [2, { max: 40 }],
|
|
73
77
|
'logical-assignment-operators': [
|
|
74
78
|
2,
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
- actionlint
|
|
18
18
|
- lint-staged
|
|
19
19
|
|
|
20
|
+
The shared oxlint config ships an opt-in `nc/prefer-assert` rule, off by default. It flags an `if`
|
|
21
|
+
whose only statement is `throw new Error(...)` (or `AppError`, `AssertionError`) that reads better
|
|
22
|
+
as `_assert(condition, message, data)` from `@naturalcycles/js-lib/error`. Test files are exempt.
|
|
23
|
+
Turn it on in your `oxlint.config.ts` with `rules: { 'nc/prefer-assert': 2 }`.
|
|
24
|
+
|
|
20
25
|
## How to use
|
|
21
26
|
|
|
22
27
|
Install it:
|