@naturalcycles/dev-lib 20.69.0 → 20.71.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': 2,
72
76
  complexity: [2, { max: 40 }],
73
77
  'logical-assignment-operators': [
74
78
  2,
@@ -92,8 +96,10 @@ export const sharedConfig = {
92
96
  'object-shorthand': 2,
93
97
  'valid-expect': 0,
94
98
  'valid-title': 0,
95
- 'vitest/valid-title': 0,
99
+ 'vitest/valid-title': [2, { mustMatch: { it: '^should ' } }],
96
100
  'valid-describe-callback': 0,
101
+ // Options arguments and variables are `opt`, never `opts`
102
+ 'id-denylist': [2, 'opts'],
97
103
  // 'id-denylist': [
98
104
  // 0, // actually not useful
99
105
  // 'any',
@@ -142,6 +148,7 @@ export const sharedConfig = {
142
148
  name: 'Intl',
143
149
  message: 'Use Intl2 instead.',
144
150
  },
151
+ { name: 'Date', message: 'Use localTime / localDate from @naturalcycles/js-lib/datetime.' },
145
152
  ],
146
153
  'no-unused-expressions': 2,
147
154
  'no-var': 2,
@@ -458,13 +465,7 @@ export const sharedConfig = {
458
465
  'vitest/prefer-called-exactly-once-with': 2,
459
466
  'vitest/consistent-test-filename': 2,
460
467
  'vitest/warn-todo': 0,
461
- 'vitest/consistent-test-it': [
462
- 2,
463
- {
464
- fn: 'test',
465
- withinDescribe: 'test',
466
- },
467
- ],
468
+ 'vitest/consistent-test-it': [2, { fn: 'it', withinDescribe: 'it' }],
468
469
  'vitest/max-nested-describe': [0, { max: 4 }], // not good
469
470
  'vitest/no-alias-methods': 2,
470
471
  'vitest/no-restricted-vi-methods': [
@@ -4,6 +4,7 @@ import { findPackageJSON } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { _isTruthy } from '@naturalcycles/js-lib';
6
6
  import { _uniq } from '@naturalcycles/js-lib/array';
7
+ import { nowUnixMillis } from '@naturalcycles/js-lib/datetime/localTime.js';
7
8
  import { _since } from '@naturalcycles/js-lib/datetime/time.util.js';
8
9
  import { _assert } from '@naturalcycles/js-lib/error/assert.js';
9
10
  import { _filterFalsyValues } from '@naturalcycles/js-lib/object/object.util.js';
@@ -24,7 +25,7 @@ const { CI } = process.env;
24
25
  */
25
26
  export async function runCheck(opt = {}) {
26
27
  const { fastLinters = true, eslint = true, stylelint = true, oxfmt = true, ktlint = true, typecheck = false, test = true, } = opt;
27
- const started = Date.now();
28
+ const started = nowUnixMillis();
28
29
  let s;
29
30
  const timings = {};
30
31
  // const { commitOnChanges, failOnChanges } = _yargs().options({
@@ -49,52 +50,52 @@ export async function runCheck(opt = {}) {
49
50
  const fix = !CI;
50
51
  if (fastLinters) {
51
52
  // Fast linters (that run in <1 second) go first
52
- s = Date.now();
53
+ s = nowUnixMillis();
53
54
  if (runActionLint()) {
54
- timings['actionlint'] = Date.now() - s;
55
+ timings['actionlint'] = nowUnixMillis() - s;
55
56
  }
56
- s = Date.now();
57
+ s = nowUnixMillis();
57
58
  if (runOxlint(fix)) {
58
- timings['oxlint'] = Date.now() - s;
59
+ timings['oxlint'] = nowUnixMillis() - s;
59
60
  }
60
61
  }
61
62
  // From this point we start the "slow" linters, with ESLint leading the way
62
63
  if (eslint) {
63
64
  // We run eslint BEFORE Prettier, because eslint can delete e.g unused imports.
64
- s = Date.now();
65
+ s = nowUnixMillis();
65
66
  if (eslintAll({ fix })) {
66
- timings['eslint'] = Date.now() - s;
67
+ timings['eslint'] = nowUnixMillis() - s;
67
68
  }
68
69
  }
69
70
  if (stylelint &&
70
71
  hasDependencyInNodeModules('stylelint') &&
71
72
  hasDependencyInNodeModules('stylelint-config-standard-scss')) {
72
- s = Date.now();
73
+ s = nowUnixMillis();
73
74
  if (stylelintAll(fix)) {
74
- timings['stylelint'] = Date.now() - s;
75
+ timings['stylelint'] = nowUnixMillis() - s;
75
76
  }
76
77
  }
77
78
  if (oxfmt) {
78
- s = Date.now();
79
+ s = nowUnixMillis();
79
80
  if (runOxfmt(fix)) {
80
- timings['oxfmt'] = Date.now() - s;
81
+ timings['oxfmt'] = nowUnixMillis() - s;
81
82
  }
82
83
  }
83
84
  if (ktlint) {
84
- s = Date.now();
85
+ s = nowUnixMillis();
85
86
  if (await runKTLint(fix)) {
86
- timings['ktlint'] = Date.now() - s;
87
+ timings['ktlint'] = nowUnixMillis() - s;
87
88
  }
88
89
  }
89
90
  if (typecheck) {
90
- s = Date.now();
91
+ s = nowUnixMillis();
91
92
  await runTypecheck();
92
- timings['tsc'] = Date.now() - s;
93
+ timings['tsc'] = nowUnixMillis() - s;
93
94
  }
94
95
  if (test) {
95
- s = Date.now();
96
+ s = nowUnixMillis();
96
97
  if (runTest()) {
97
- timings['test'] = Date.now() - s;
98
+ timings['test'] = nowUnixMillis() - s;
98
99
  }
99
100
  }
100
101
  console.log(`${check(true)}${white(`check`)} ${dimGrey(`took ` + _since(started))}`);
@@ -1,3 +1,4 @@
1
+ import { localDate } from '@naturalcycles/js-lib/datetime/localDate.js';
1
2
  /**
2
3
  * Generate release notes markdown for a GitHub Release,
3
4
  * following the format of `@semantic-release/release-notes-generator` (angular preset):
@@ -5,7 +6,7 @@
5
6
  */
6
7
  export function generateReleaseNotes(input) {
7
8
  const { repo, commits, lastTag, nextTag } = input;
8
- const date = input.date || new Date().toISOString().slice(0, 10);
9
+ const date = input.date || localDate.todayInUTC().toISODate();
9
10
  const header = lastTag
10
11
  ? `## [${nextTag}](${repo.url}/compare/${lastTag}...${nextTag}) (${date})`
11
12
  : `## ${nextTag} (${date})`;
@@ -44,6 +44,7 @@ export const mockAllKindsOfThings = () => {
44
44
  k1: 'v1',
45
45
  }),
46
46
  Symbol('a_am_symbol'),
47
+ // oxlint-disable-next-line no-restricted-globals -- a native Date is one of the kinds of things
47
48
  new Date(1984, 5, 21),
48
49
  ];
49
50
  };
@@ -11,7 +11,7 @@ export interface BypassableSocket extends NetType.Socket {
11
11
  bypass: () => void;
12
12
  bypassed?: boolean;
13
13
  }
14
- export type ConnectCallback = (socket: BypassableSocket, opts: SocketOptions) => void;
14
+ export type ConnectCallback = (socket: BypassableSocket, opt: SocketOptions) => void;
15
15
  export interface Mitm {
16
16
  on: (event: 'connect', callback: ConnectCallback) => void;
17
17
  disable: () => void;
@@ -18,7 +18,7 @@ export function createMitm() {
18
18
  s[0][s[1]] = s[2];
19
19
  }
20
20
  }
21
- function connect(orig, opts, done) {
21
+ function connect(orig, opt, done) {
22
22
  // Create a bypassable socket that we'll pass to listeners
23
23
  const socket = new Net.Socket();
24
24
  socket.bypassed = false;
@@ -27,11 +27,11 @@ export function createMitm() {
27
27
  };
28
28
  // Emit connect event to all listeners
29
29
  for (const listener of listeners) {
30
- listener(socket, opts);
30
+ listener(socket, opt);
31
31
  }
32
32
  // If bypassed, call the original connect function
33
33
  if (socket.bypassed) {
34
- return orig.call(Net, opts, done);
34
+ return orig.call(Net, opt, done);
35
35
  }
36
36
  // Not bypassed - return the socket that will never actually connect.
37
37
  // testOffline throws an error before we get here, so this is just a fallback.
@@ -43,12 +43,12 @@ export function createMitm() {
43
43
  // connect(options, callback?)
44
44
  // connect(path, callback?) - Unix socket
45
45
  if (typeof args[0] === 'number') {
46
- const opts = { port: args[0] };
46
+ const opt = { port: args[0] };
47
47
  if (typeof args[1] === 'string') {
48
- opts.host = args[1];
49
- return [opts, args[2]];
48
+ opt.host = args[1];
49
+ return [opt, args[2]];
50
50
  }
51
- return [opts, args[1]];
51
+ return [opt, args[1]];
52
52
  }
53
53
  if (typeof args[0] === 'object' && args[0] !== null) {
54
54
  return [args[0], args[1]];
@@ -58,14 +58,14 @@ export function createMitm() {
58
58
  }
59
59
  function createNetConnect(orig) {
60
60
  return function (...args) {
61
- const [opts, done] = normalizeArgs(args);
62
- return connect(orig, opts, done);
61
+ const [opt, done] = normalizeArgs(args);
62
+ return connect(orig, opt, done);
63
63
  };
64
64
  }
65
65
  function createTlsConnect(orig) {
66
66
  return function (...args) {
67
- const [opts, done] = normalizeArgs(args);
68
- return connect(orig, opts, done);
67
+ const [opt, done] = normalizeArgs(args);
68
+ return connect(orig, opt, done);
69
69
  };
70
70
  }
71
71
  // Stub network functions
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/dev-lib",
3
3
  "type": "module",
4
- "version": "20.69.0",
4
+ "version": "20.71.0",
5
5
  "dependencies": {
6
6
  "prompts": "^2",
7
7
  "@naturalcycles/js-lib": "^15",