@naturalcycles/dev-lib 20.68.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.
@@ -0,0 +1,10 @@
1
+ import type { RuleTester } from 'oxlint/plugins-dev'
2
+
3
+ type Rule = Parameters<RuleTester['run']>[1]
4
+
5
+ declare const ncPlugin: {
6
+ meta: { name: string }
7
+ rules: { 'prefer-assert': Rule }
8
+ }
9
+
10
+ export default ncPlugin
@@ -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
@@ -39,7 +39,10 @@ export const sharedConfig = {
39
39
  // "vue",
40
40
  'vitest',
41
41
  ],
42
- jsPlugins: [`${import.meta.dirname}/oxlint-plugin-stylistic.js`],
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,
@@ -173,5 +173,5 @@ async function cleanBuild() {
173
173
  buildProd();
174
174
  }
175
175
  function cleanDist() {
176
- fs2.emptyDir('./dist'); // it doesn't delete the dir itself, to prevent IDE jumping
176
+ fs2.ensureEmptyDir('./dist'); // it doesn't delete the dir itself, to prevent IDE jumping
177
177
  }
@@ -310,7 +310,7 @@ export function getActionLintVersion() {
310
310
  return exec2.exec('actionlint --version').split('\n')[0];
311
311
  }
312
312
  export function buildProd() {
313
- // fs2.emptyDir('./dist') // it doesn't delete the dir itself, to prevent IDE jumping
313
+ // fs2.ensureEmptyDir('./dist') // it doesn't delete the dir itself, to prevent IDE jumping
314
314
  buildCopy();
315
315
  runTSCProd();
316
316
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/dev-lib",
3
3
  "type": "module",
4
- "version": "20.68.0",
4
+ "version": "20.70.0",
5
5
  "dependencies": {
6
6
  "prompts": "^2",
7
7
  "@naturalcycles/js-lib": "^15",
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: