@avi2dg/checks 0.16.0 → 0.17.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.17.0
6
+
7
+ Released 2026-09-25.
8
+
9
+ ### Features
10
+
11
+ - **effect-channel:** add a cognitive complexity rule and make it the size budget's (#50)
12
+ - **scripts:** export the refused directive names from comment-matchers (#49)
13
+ - refuse undeclared package imports and deprecated symbol use (#48)
14
+
5
15
  ## 0.16.0
6
16
 
7
17
  Released 2026-09-25.
package/CONTRIBUTING.md CHANGED
@@ -65,7 +65,7 @@ To place a change:
65
65
  | Path | What it holds |
66
66
  | --- | --- |
67
67
  | `scripts/` | every bin, and the modules they share |
68
- | `effect-channel/` | the Effect error-channel oxlint plugin |
68
+ | `effect-channel/` | the oxlint plugin with the Effect error-channel and cognitive complexity rules |
69
69
  | `dist/` | the committed bundles of the plugin and of `featureRules` |
70
70
  | `presets/` | the Effect presets `checks-quality` builds its fragments from |
71
71
  | `templates/` | one template per kind of doc file, which `bun run build` renders |
package/README.md CHANGED
@@ -170,7 +170,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
170
170
  | `oxlintrc.json` | the oxlint base config `.oxlintrc.json` extends |
171
171
  | `stryker.preset.js` | the Stryker mutation-testing preset |
172
172
  | `tsconfig.effect.json` | the tsconfig fragment with the Effect language-service block |
173
- | `dist/` | the compiled Effect error-channel plugin and `featureRules` |
173
+ | `dist/` | the compiled oxlint plugin with the Effect error-channel and cognitive complexity rules, and `featureRules` |
174
174
 
175
175
  <!-- end generated shipped -->
176
176
 
@@ -178,6 +178,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
178
178
 
179
179
  - [The quality file](docs/configs/quality-file.md)
180
180
  - [The Effect rules](docs/configs/effect-rules.md)
181
+ - [The TypeScript rules](docs/configs/typescript-rules.md)
181
182
  - [The dependency rules](docs/configs/dependency-rules.md)
182
183
  - [The commit message lint](docs/configs/commit-messages.md)
183
184
  - [Why it is shaped this way](docs/design.md)
@@ -41,6 +41,14 @@ export default {
41
41
  dependencyTypesNot: ["type-only", "npm-peer"],
42
42
  },
43
43
  },
44
+ {
45
+ name: "no-non-package-json",
46
+ severity: "error",
47
+ comment:
48
+ "The import resolves to an installed package the nearest package.json does not declare, so it holds only while something else keeps it hoisted. Declare it in dependencies, devDependencies or peerDependencies.",
49
+ from: {},
50
+ to: { dependencyTypes: ["npm-no-pkg", "npm-unknown"] },
51
+ },
44
52
  {
45
53
  name: "not-to-unresolvable",
46
54
  severity: "error",
@@ -59,10 +59,11 @@ var STATEMENTS = {
59
59
  };
60
60
  var COMPLEXITY = {
61
61
  key: "complexity",
62
- rule: "complexity",
63
- options: { variant: "modified" },
64
- measured: /has a complexity of (\d+)/,
65
- limits: "The highest cyclomatic complexity a function may reach, a switch counted once"
62
+ rule: "cognitive-complexity",
63
+ plugin: "effect-channel",
64
+ options: {},
65
+ measured: /has a cognitive complexity of (\d+)/,
66
+ limits: "The highest cognitive complexity a function may reach, a switch counted once"
66
67
  };
67
68
  var DEPTH = {
68
69
  key: "depth",
package/dist/index.js CHANGED
@@ -1,3 +1,421 @@
1
+ // effect-channel/cognitive-nodes.ts
2
+ var CONTROL_TYPES = ["IfStatement", "ConditionalExpression", "SwitchStatement", "SwitchCase", "TryStatement", "CatchClause"];
3
+ var LOOP_TYPES = ["ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "LabeledStatement"];
4
+ var CALL_TYPES = ["LogicalExpression", "BreakStatement", "ContinueStatement", "CallExpression", "NewExpression", "ImportExpression"];
5
+ var FUNCTION_TYPES = ["FunctionDeclaration", "FunctionExpression", "TSDeclareFunction", "TSEmptyBodyFunctionExpression", "ArrowFunctionExpression", "StaticBlock"];
6
+ var PLAIN_A_TYPES = ["BlockStatement", "ExpressionStatement", "ReturnStatement", "ThrowStatement", "VariableDeclaration", "VariableDeclarator", "AssignmentPattern", "ObjectPattern", "ArrayPattern", "Property", "RestElement", "TSParameterProperty", "WithStatement", "TSEnumDeclaration", "TSEnumBody", "TSEnumMember"];
7
+ var PLAIN_B_TYPES = ["ClassDeclaration", "ClassExpression", "ClassBody", "MethodDefinition", "TSAbstractMethodDefinition", "PropertyDefinition", "TSAbstractPropertyDefinition", "AccessorProperty", "TSAbstractAccessorProperty", "ObjectExpression", "ArrayExpression"];
8
+ var PLAIN_C_TYPES = ["AwaitExpression", "UnaryExpression", "UpdateExpression", "SpreadElement", "YieldExpression", "BinaryExpression", "AssignmentExpression", "TSAsExpression", "TSSatisfiesExpression", "TSTypeAssertion", "TSNonNullExpression", "ChainExpression", "ParenthesizedExpression", "Decorator", "TSInstantiationExpression", "MemberExpression", "JSXMemberExpression", "TemplateLiteral", "TaggedTemplateExpression", "SequenceExpression", "JSXElement", "JSXFragment", "JSXOpeningElement", "JSXAttribute", "JSXExpressionContainer", "JSXSpreadChild", "JSXSpreadAttribute"];
9
+
10
+ // effect-channel/cognitive-plain.ts
11
+ function isControl(node) {
12
+ return CONTROL_TYPES.includes(node.type);
13
+ }
14
+ function isLoop(node) {
15
+ return LOOP_TYPES.includes(node.type);
16
+ }
17
+ function isCall(node) {
18
+ return CALL_TYPES.includes(node.type);
19
+ }
20
+ function isFunction(node) {
21
+ return FUNCTION_TYPES.includes(node.type);
22
+ }
23
+ function isPlainA(node) {
24
+ return PLAIN_A_TYPES.includes(node.type);
25
+ }
26
+ function isPlainB(node) {
27
+ return PLAIN_B_TYPES.includes(node.type);
28
+ }
29
+ function isPlainC(node) {
30
+ return PLAIN_C_TYPES.includes(node.type);
31
+ }
32
+ function unreachable(_value) {}
33
+ function plainChildrenA(node) {
34
+ switch (node.type) {
35
+ case "BlockStatement":
36
+ return node.body;
37
+ case "ExpressionStatement":
38
+ return [node.expression];
39
+ case "ReturnStatement":
40
+ case "ThrowStatement":
41
+ return [node.argument];
42
+ case "VariableDeclaration":
43
+ return node.declarations;
44
+ case "VariableDeclarator":
45
+ return [node.id, node.init];
46
+ case "AssignmentPattern":
47
+ return [node.left, node.right];
48
+ case "ObjectPattern":
49
+ return node.properties;
50
+ case "ArrayPattern":
51
+ return node.elements;
52
+ case "Property":
53
+ return [node.key, node.value];
54
+ case "RestElement":
55
+ return [node.argument];
56
+ case "TSParameterProperty":
57
+ return [node.parameter];
58
+ case "WithStatement":
59
+ return [node.object, node.body];
60
+ case "TSEnumDeclaration":
61
+ return [node.body];
62
+ case "TSEnumBody":
63
+ return node.members;
64
+ case "TSEnumMember":
65
+ return [node.initializer];
66
+ default:
67
+ unreachable(node);
68
+ return [];
69
+ }
70
+ }
71
+ function plainChildrenB(node) {
72
+ switch (node.type) {
73
+ case "ClassDeclaration":
74
+ case "ClassExpression":
75
+ return [...node.decorators, node.id, node.superClass, node.body];
76
+ case "ClassBody":
77
+ return node.body;
78
+ case "MethodDefinition":
79
+ case "TSAbstractMethodDefinition":
80
+ return [node.key, node.value];
81
+ case "PropertyDefinition":
82
+ case "TSAbstractPropertyDefinition":
83
+ case "AccessorProperty":
84
+ case "TSAbstractAccessorProperty":
85
+ return [node.key, node.value];
86
+ case "ObjectExpression":
87
+ return node.properties;
88
+ case "ArrayExpression":
89
+ return node.elements;
90
+ default:
91
+ unreachable(node);
92
+ return [];
93
+ }
94
+ }
95
+ function plainChildrenC(node) {
96
+ switch (node.type) {
97
+ case "AwaitExpression":
98
+ case "UnaryExpression":
99
+ case "UpdateExpression":
100
+ case "SpreadElement":
101
+ return [node.argument];
102
+ case "YieldExpression":
103
+ return [node.argument];
104
+ case "BinaryExpression":
105
+ return [node.left, node.right];
106
+ case "AssignmentExpression":
107
+ return [node.left, node.right];
108
+ case "TSAsExpression":
109
+ case "TSSatisfiesExpression":
110
+ case "TSTypeAssertion":
111
+ case "TSNonNullExpression":
112
+ case "ChainExpression":
113
+ case "ParenthesizedExpression":
114
+ case "Decorator":
115
+ case "TSInstantiationExpression":
116
+ return [node.expression];
117
+ case "MemberExpression":
118
+ case "JSXMemberExpression":
119
+ return [node.object, node.property];
120
+ case "TemplateLiteral":
121
+ return node.expressions;
122
+ case "TaggedTemplateExpression":
123
+ return [node.tag, node.quasi];
124
+ case "SequenceExpression":
125
+ return node.expressions;
126
+ case "JSXElement":
127
+ return [node.openingElement, ...node.children];
128
+ case "JSXFragment":
129
+ return node.children;
130
+ case "JSXOpeningElement":
131
+ return node.attributes;
132
+ case "JSXAttribute":
133
+ return [node.value];
134
+ case "JSXExpressionContainer":
135
+ case "JSXSpreadChild":
136
+ return [node.expression];
137
+ case "JSXSpreadAttribute":
138
+ return [node.argument];
139
+ default:
140
+ unreachable(node);
141
+ return [];
142
+ }
143
+ }
144
+
145
+ // effect-channel/cognitive.ts
146
+ function unreachable2(_value) {}
147
+ function scoreList(state, nodes, nesting, parent) {
148
+ for (const node of nodes) {
149
+ if (node !== null)
150
+ score(state, node, nesting, parent);
151
+ }
152
+ }
153
+ function score(state, node, nesting, parent) {
154
+ if (isControl(node))
155
+ return scoreControl(state, node, nesting);
156
+ if (isLoop(node))
157
+ return scoreLoop(state, node, nesting);
158
+ if (isCall(node))
159
+ return scoreCall(state, node, nesting, parent);
160
+ if (isFunction(node))
161
+ return;
162
+ if (isPlainA(node))
163
+ return scoreList(state, plainChildrenA(node), nesting, node);
164
+ if (isPlainB(node))
165
+ return scoreList(state, plainChildrenB(node), nesting, node);
166
+ if (isPlainC(node))
167
+ return scoreList(state, plainChildrenC(node), nesting, node);
168
+ }
169
+ function scoreBranch(state, node, nesting) {
170
+ score(state, node.test, nesting, node);
171
+ score(state, node.consequent, nesting + 1, node);
172
+ const alternate = node.alternate;
173
+ if (alternate === null)
174
+ return;
175
+ state.total += 1;
176
+ if (alternate.type === "IfStatement")
177
+ return scoreBranch(state, alternate, nesting);
178
+ score(state, alternate, nesting + 1, node);
179
+ }
180
+ function scoreIf(state, node, nesting) {
181
+ state.total += 1 + nesting;
182
+ scoreBranch(state, node, nesting);
183
+ }
184
+ function scoreControl(state, node, nesting) {
185
+ switch (node.type) {
186
+ case "IfStatement": {
187
+ return scoreIf(state, node, nesting);
188
+ }
189
+ case "ConditionalExpression": {
190
+ state.total += 1 + nesting;
191
+ score(state, node.test, nesting, node);
192
+ score(state, node.consequent, nesting + 1, node);
193
+ score(state, node.alternate, nesting + 1, node);
194
+ return;
195
+ }
196
+ case "SwitchStatement": {
197
+ state.total += 1 + nesting;
198
+ score(state, node.discriminant, nesting, node);
199
+ scoreList(state, node.cases, nesting + 1, node);
200
+ return;
201
+ }
202
+ case "SwitchCase": {
203
+ if (node.test !== null)
204
+ score(state, node.test, nesting, node);
205
+ scoreList(state, node.consequent, nesting, node);
206
+ return;
207
+ }
208
+ case "TryStatement": {
209
+ score(state, node.block, nesting, node);
210
+ if (node.handler !== null)
211
+ score(state, node.handler, nesting, node);
212
+ if (node.finalizer !== null)
213
+ score(state, node.finalizer, nesting, node);
214
+ return;
215
+ }
216
+ case "CatchClause": {
217
+ state.total += 1 + nesting;
218
+ if (node.param !== null)
219
+ score(state, node.param, nesting, node);
220
+ score(state, node.body, nesting + 1, node);
221
+ return;
222
+ }
223
+ default: {
224
+ return unreachable2(node);
225
+ }
226
+ }
227
+ }
228
+ function scoreLoop(state, node, nesting) {
229
+ switch (node.type) {
230
+ case "ForStatement": {
231
+ state.total += 1 + nesting;
232
+ if (node.init !== null)
233
+ score(state, node.init, nesting, node);
234
+ if (node.test !== null)
235
+ score(state, node.test, nesting, node);
236
+ if (node.update !== null)
237
+ score(state, node.update, nesting, node);
238
+ score(state, node.body, nesting + 1, node);
239
+ return;
240
+ }
241
+ case "ForInStatement":
242
+ case "ForOfStatement": {
243
+ state.total += 1 + nesting;
244
+ score(state, node.left, nesting, node);
245
+ score(state, node.right, nesting, node);
246
+ score(state, node.body, nesting + 1, node);
247
+ return;
248
+ }
249
+ case "WhileStatement": {
250
+ state.total += 1 + nesting;
251
+ score(state, node.test, nesting, node);
252
+ score(state, node.body, nesting + 1, node);
253
+ return;
254
+ }
255
+ case "DoWhileStatement": {
256
+ state.total += 1 + nesting;
257
+ score(state, node.body, nesting + 1, node);
258
+ score(state, node.test, nesting, node);
259
+ return;
260
+ }
261
+ case "LabeledStatement": {
262
+ score(state, node.body, nesting, node);
263
+ return;
264
+ }
265
+ default: {
266
+ return unreachable2(node);
267
+ }
268
+ }
269
+ }
270
+ function insideRun(parent) {
271
+ return parent !== null && parent.type === "LogicalExpression" && (parent.operator === "&&" || parent.operator === "||");
272
+ }
273
+ function countRuns(node, parentOperator) {
274
+ if (node.type !== "LogicalExpression")
275
+ return 0;
276
+ if (node.operator !== "&&" && node.operator !== "||")
277
+ return 0;
278
+ const own = node.operator === parentOperator ? 0 : 1;
279
+ return own + countRuns(node.left, node.operator) + countRuns(node.right, node.operator);
280
+ }
281
+ function isSelfCall(state, callee) {
282
+ if (callee.type === "Identifier")
283
+ return state.names.identifiers.includes(callee.name);
284
+ if (callee.type === "MemberExpression" && callee.object.type === "ThisExpression" && callee.property.type === "Identifier") {
285
+ return state.names.members.includes(callee.property.name);
286
+ }
287
+ return false;
288
+ }
289
+ function scoreCall(state, node, nesting, parent) {
290
+ switch (node.type) {
291
+ case "LogicalExpression": {
292
+ if (!insideRun(parent) && (node.operator === "&&" || node.operator === "||"))
293
+ state.total += countRuns(node, null);
294
+ score(state, node.left, nesting, node);
295
+ score(state, node.right, nesting, node);
296
+ return;
297
+ }
298
+ case "BreakStatement":
299
+ case "ContinueStatement": {
300
+ if (node.label !== null)
301
+ state.total += 1;
302
+ return;
303
+ }
304
+ case "CallExpression":
305
+ case "NewExpression": {
306
+ if (isSelfCall(state, node.callee))
307
+ state.recursive = true;
308
+ score(state, node.callee, nesting, node);
309
+ scoreList(state, node.arguments, nesting, node);
310
+ return;
311
+ }
312
+ case "ImportExpression": {
313
+ score(state, node.source, nesting, node);
314
+ if (node.options !== null)
315
+ score(state, node.options, nesting, node);
316
+ return;
317
+ }
318
+ default: {
319
+ return unreachable2(node);
320
+ }
321
+ }
322
+ }
323
+ function cognitiveComplexity(root, names) {
324
+ const state = { total: 0, recursive: false, names };
325
+ if (root.type === "StaticBlock") {
326
+ scoreList(state, root.body, 0, root);
327
+ return state.total;
328
+ }
329
+ scoreList(state, root.params, 0, root);
330
+ if (root.type === "ArrowFunctionExpression")
331
+ score(state, root.body, 0, root);
332
+ else if (root.body !== null)
333
+ score(state, root.body, 0, root);
334
+ return state.recursive ? state.total + 1 : state.total;
335
+ }
336
+
337
+ // effect-channel/cognitive-complexity.ts
338
+ var DEFAULT_MAX = 15;
339
+ function maxOf(options) {
340
+ const [first] = options;
341
+ if (typeof first === "object" && first !== null && "max" in first && typeof first.max === "number" && first.max > 0) {
342
+ return Math.floor(first.max);
343
+ }
344
+ return DEFAULT_MAX;
345
+ }
346
+ function keyName(holder) {
347
+ if (holder.computed)
348
+ return;
349
+ if (holder.key.type === "Identifier")
350
+ return holder.key.name;
351
+ if (holder.key.type === "Literal" && typeof holder.key.value === "string")
352
+ return holder.key.value;
353
+ return;
354
+ }
355
+ function assignedBinding(target) {
356
+ if (target.type === "Identifier")
357
+ return { kind: "identifiers", name: target.name };
358
+ if (target.type === "MemberExpression" && target.object.type === "ThisExpression" && target.property.type === "Identifier") {
359
+ return { kind: "members", name: target.property.name };
360
+ }
361
+ return;
362
+ }
363
+ function memberBinding(holder) {
364
+ const name = keyName(holder);
365
+ return name === undefined ? undefined : { kind: "members", name };
366
+ }
367
+ function binding(node) {
368
+ const parent = node.parent;
369
+ if (parent.type === "VariableDeclarator" && parent.init === node && parent.id.type === "Identifier") {
370
+ return { kind: "identifiers", name: parent.id.name };
371
+ }
372
+ if ((parent.type === "Property" || parent.type === "MethodDefinition" || parent.type === "PropertyDefinition" || parent.type === "AccessorProperty") && parent.value === node) {
373
+ return memberBinding(parent);
374
+ }
375
+ if (parent.type === "AssignmentExpression" && parent.right === node)
376
+ return assignedBinding(parent.left);
377
+ return;
378
+ }
379
+ function displayName(node) {
380
+ if (node.type !== "ArrowFunctionExpression" && node.id !== null)
381
+ return node.id.name;
382
+ return binding(node)?.name ?? "anonymous";
383
+ }
384
+ function selfNames(node) {
385
+ const own = node.type === "ArrowFunctionExpression" || node.id === null ? [] : [node.id.name];
386
+ const bound = binding(node);
387
+ return {
388
+ identifiers: bound?.kind === "identifiers" ? [...own, bound.name] : own,
389
+ members: bound?.kind === "members" ? [bound.name] : []
390
+ };
391
+ }
392
+ var NO_NAMES = { identifiers: [], members: [] };
393
+ var rule = {
394
+ meta: {
395
+ type: "problem",
396
+ docs: { description: "Hold each function to a cognitive complexity of 15" },
397
+ schema: [{ type: "object", properties: { max: { type: "number" } }, additionalProperties: false }],
398
+ defaultOptions: [{ max: DEFAULT_MAX }]
399
+ },
400
+ create(context) {
401
+ const max = maxOf(context.options);
402
+ const check = (node) => {
403
+ const score2 = node.type === "StaticBlock" ? cognitiveComplexity(node, NO_NAMES) : cognitiveComplexity(node, selfNames(node));
404
+ if (score2 <= max)
405
+ return;
406
+ const name = node.type === "StaticBlock" ? "static block" : `function \`${displayName(node)}\``;
407
+ context.report({ node, message: `${name} has a cognitive complexity of ${score2}. Maximum allowed is ${max}.` });
408
+ };
409
+ return {
410
+ FunctionDeclaration: check,
411
+ FunctionExpression: check,
412
+ ArrowFunctionExpression: check,
413
+ StaticBlock: check
414
+ };
415
+ }
416
+ };
417
+ var cognitive_complexity_default = rule;
418
+
1
419
  // effect-channel/no-error-channel-escape.ts
2
420
  var EFFECT_SOURCES = new Set(["effect", "effect/Effect"]);
3
421
  var INSTEAD = {
@@ -19,7 +437,7 @@ var blindToTheError = (handler) => {
19
437
  return false;
20
438
  return handler.params.every((param) => param.type === "Identifier" && /^_+$/.test(param.name));
21
439
  };
22
- var rule = {
440
+ var rule2 = {
23
441
  meta: {
24
442
  type: "problem",
25
443
  docs: { description: "Disallow the combinators that erase Effect's error channel" }
@@ -67,10 +485,10 @@ var rule = {
67
485
  };
68
486
  }
69
487
  };
70
- var no_error_channel_escape_default = rule;
488
+ var no_error_channel_escape_default = rule2;
71
489
 
72
490
  // effect-channel/no-throw.ts
73
- var rule2 = {
491
+ var rule3 = {
74
492
  meta: {
75
493
  type: "problem",
76
494
  docs: { description: "Disallow throw, which fails outside Effect's error channel" }
@@ -86,10 +504,10 @@ var rule2 = {
86
504
  };
87
505
  }
88
506
  };
89
- var no_throw_default = rule2;
507
+ var no_throw_default = rule3;
90
508
 
91
509
  // effect-channel/no-try-catch.ts
92
- var rule3 = {
510
+ var rule4 = {
93
511
  meta: {
94
512
  type: "problem",
95
513
  docs: { description: "Disallow a try statement with a catch clause, which recovers outside Effect's error channel" }
@@ -105,7 +523,7 @@ var rule3 = {
105
523
  };
106
524
  }
107
525
  };
108
- var no_try_catch_default = rule3;
526
+ var no_try_catch_default = rule4;
109
527
 
110
528
  // effect-channel/index.ts
111
529
  var plugin = {
@@ -113,7 +531,8 @@ var plugin = {
113
531
  rules: {
114
532
  "no-error-channel-escape": no_error_channel_escape_default,
115
533
  "no-throw": no_throw_default,
116
- "no-try-catch": no_try_catch_default
534
+ "no-try-catch": no_try_catch_default,
535
+ "cognitive-complexity": cognitive_complexity_default
117
536
  }
118
537
  };
119
538
  var effect_channel_default = plugin;
@@ -9,6 +9,7 @@ The shared dependency-cruiser base holds a repository's imports to a set of rule
9
9
  - `no-circular`
10
10
  - `no-orphans`
11
11
  - `not-to-dev-dep`, which refuses shipped source importing a dev-only package, and a package listed in `peerDependencies` too is not dev-only
12
+ - `no-non-package-json`, which refuses an import of an installed package that the nearest `package.json` does not declare
12
13
  - `not-to-unresolvable`, which refuses a specifier nothing installed answers
13
14
  - `no-deep-imports`, which refuses a subpath the package's exports map does not publish
14
15
 
@@ -43,6 +43,7 @@ effect-tsgo keeps the severities `tsconfig.effect.json` sets when a later config
43
43
 
44
44
  ## Related topics
45
45
 
46
+ - [The TypeScript rules](typescript-rules.md)
46
47
  - [checks-quality](../gates/checks-quality.md)
47
48
  - [The quality file](quality-file.md)
48
49
  - [Why it is shaped this way](../design.md)
@@ -0,0 +1,36 @@
1
+ # The TypeScript rules
2
+
3
+ The oxlint base config holds a repository's TypeScript to a set of rules, and a reader looks it up to learn what each rule refuses and which rules need type information.
4
+
5
+ ## Syntax rules
6
+
7
+ `oxlintrc.json` turns on the `correctness` and `suspicious` categories as errors, and these rules on top of them:
8
+
9
+ - `typescript/no-explicit-any` refuses an `any` type written out.
10
+ - `typescript/ban-ts-comment` refuses `@ts-ignore`, `@ts-expect-error` and `@ts-nocheck`.
11
+ - `typescript/no-inferrable-types` refuses a type annotation on a variable or a parameter default whose literal initializer already gives the type.
12
+ - `typescript/explicit-module-boundary-types` refuses an exported function without a return type, and an exported function parameter typed `any`.
13
+ - `typescript/no-non-null-assertion` refuses the non-null assertion `!`.
14
+ - `eslint/no-unused-vars` refuses a variable, a parameter or an import nothing reads, and passes over a variable or a parameter whose name starts with `_` and the siblings of a rest property.
15
+
16
+ The base turns off `typescript/consistent-return`, which its categories would otherwise turn on.
17
+ It turns on `effect-channel/no-error-channel-escape` as well, and [The Effect rules](effect-rules.md) says what that rule refuses.
18
+
19
+ ## Type-aware rules
20
+
21
+ These rules read the types, so they run only under `oxlint --type-aware` with `oxlint-tsgolint` installed.
22
+ Without the flag, oxlint skips them and reports nothing about them.
23
+
24
+ - `typescript/switch-exhaustiveness-check` refuses a `switch` over a union that leaves a member without a case.
25
+ - `typescript/prefer-readonly` refuses a private member that nothing reassigns and that is not `readonly`.
26
+ - `typescript/no-unnecessary-condition` refuses a condition whose type makes its result always the same, such as `??` on a value that cannot be null, and passes over a constant loop condition.
27
+ - `typescript/no-unnecessary-type-parameters` refuses a type parameter the signature uses only once.
28
+ - `typescript/use-unknown-in-catch-callback-variable` refuses a rejection callback whose parameter is not typed `unknown`.
29
+ - `typescript/no-unsafe-type-assertion` refuses an `as` that narrows a value to a type the compiler cannot prove.
30
+ - `typescript/no-deprecated` refuses a use of a symbol whose declaration carries a `@deprecated` tag, in the repository's own code or in a package's types, and repeats the tag's text.
31
+
32
+ ## Related topics
33
+
34
+ - [The Effect rules](effect-rules.md)
35
+ - [The dependency rules](dependency-rules.md)
36
+ - [Why it is shaped this way](../design.md)
package/docs/design.md CHANGED
@@ -13,7 +13,9 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
13
13
  Node refuses to type-strip a `.ts` plugin under `node_modules`, so the `.ts` source would fail to load from an installed package.
14
14
  - `featureRules` ships compiled as `dist/feature-rules.js` for the same reason, with `effect` left out of the bundle so it resolves the consumer's own copy.
15
15
  dependency-cruiser uses a config's export as it is and never awaits it, so the declaration decodes synchronously, and `quality.json` exempts that one file from the Effect rules.
16
- - `checks-size-budget` writes the head commit's files to a temporary directory and runs oxlint there, with a configuration that sets no plugin and turns every category off, so the consumer's own `.oxlintrc.json`, its ignore files and its other rules never reach the count.
16
+ - `checks-size-budget` writes the head commit's files to a temporary directory and runs oxlint there.
17
+ Its configuration loads the kit's own plugin bundle for the complexity rule and turns every category off.
18
+ The consumer's own `.oxlintrc.json`, its ignore files and its other rules never reach the count.
17
19
  - `checks-size-budget` ratchets against the base of the range rather than a committed baseline such as `oxlint-suppressions.json`.
18
20
  A suppression file stores a count of sites per file and rule, and `max-lines` reports a file once however long it grows, so the count stays at one while the file doubles.
19
21
  The gate sums how far each site runs over its limit instead, which grows with the file.
@@ -18,6 +18,8 @@ It passes over a file with any other extension.
18
18
  `scripts/comment-matchers.ts` holds the scanner, the comment syntaxes and a synchronous `refused()`, and imports nothing.
19
19
  A host such as a hook bundle can therefore copy it alone into a directory with no `node_modules` and import it as `@avi2dg/checks/scripts/comment-matchers.ts`.
20
20
  The kit's own dependency cruise fails when that file gains an import.
21
+ `REFUSED_DIRECTIVES` in that file owns the refused directive names, and the checker builds its directive pattern from that list, so the two cannot disagree.
22
+ A consumer reads the same contract by importing `REFUSED_DIRECTIVES` from `@avi2dg/checks/scripts/comment-matchers.ts`.
21
23
  `scripts/comments.ts` wraps the same matchers in Effect for the gate and for [checks-backtest](checks-backtest.md).
22
24
 
23
25
  ## Arguments
@@ -13,7 +13,7 @@ It holds production and test files to the size budget, and lists every other fil
13
13
 
14
14
  A production file is one under `sources.production`, and a test file is a tracked `.ts` or `.tsx` file under `tests/`.
15
15
  A test file keeps to the tests budget, and every other file keeps to the production budget.
16
- It runs oxlint with a configuration of five rules and nothing else, one for each limit.
16
+ It runs oxlint with a configuration of five rules, one for each limit, and the kit's own plugin bundle, which holds the complexity rule.
17
17
  The kit sets each limit:
18
18
 
19
19
  <!-- generated size-limits: bun run build writes it from SIZE_RULES and SIZE_DEFAULTS in scripts/size-rules.ts and scripts/doc-blocks.ts -->
@@ -23,7 +23,7 @@ The kit sets each limit:
23
23
  | `fileLines` | The most lines a file may hold, blank and comment lines counted | `max-lines` | 400 | 600 |
24
24
  | `functionLines` | The most lines a function may span, blank and comment lines counted | `max-lines-per-function` | 100 | none |
25
25
  | `statements` | The most statements a function may hold | `max-statements` | 30 | 50 |
26
- | `complexity` | The highest cyclomatic complexity a function may reach, a switch counted once | `complexity` | 15 | 15 |
26
+ | `complexity` | The highest cognitive complexity a function may reach, a switch counted once | `effect-channel/cognitive-complexity` | 15 | 15 |
27
27
  | `depth` | The deepest a block may nest inside a function | `max-depth` | 4 | 4 |
28
28
 
29
29
  <!-- end generated size-limits -->
@@ -88,8 +88,8 @@ With one it is that commit against its parent, or against the empty tree for a r
88
88
  size-budget: 2 overrun(s) grew past the base in the production and test files the range adds or changes:
89
89
  src/billing/invoice.ts: max-lines over by 31 in total, up from 19
90
90
  src/billing/invoice.ts: File has too many lines (431). Maximum allowed is 400.
91
- src/billing/ledger.ts: complexity over by 3 in total, up from 0
92
- src/billing/ledger.ts:12: function `settle` has a complexity of 18. Maximum allowed is 15.
91
+ src/billing/ledger.ts: cognitive-complexity over by 3 in total, up from 0
92
+ src/billing/ledger.ts:12: function `settle` has a cognitive complexity of 18. Maximum allowed is 15.
93
93
  size-budget: advisory, 1 overrun(s) where the budget does not hold yet:
94
94
  tests/e2e/billing.test.ts: File has too many lines (612). Maximum allowed is 600.
95
95
  ```
package/oxlintrc.json CHANGED
@@ -16,6 +16,7 @@
16
16
  "eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true }],
17
17
  "typescript/no-unsafe-type-assertion": "error",
18
18
  "typescript/no-non-null-assertion": "error",
19
+ "typescript/no-deprecated": "error",
19
20
  "typescript/consistent-return": "off"
20
21
  }
21
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -178,7 +178,7 @@
178
178
  "complexity": {
179
179
  "type": "integer",
180
180
  "exclusiveMinimum": 0,
181
- "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
181
+ "description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
182
182
  },
183
183
  "depth": {
184
184
  "type": "integer",
@@ -205,7 +205,7 @@
205
205
  "complexity": {
206
206
  "type": "integer",
207
207
  "exclusiveMinimum": 0,
208
- "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
208
+ "description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
209
209
  },
210
210
  "depth": {
211
211
  "type": "integer",
@@ -244,6 +244,24 @@ function openingBlock(found: readonly Comment[], source: string): Comment[] {
244
244
  return block;
245
245
  }
246
246
 
247
+ export const REFUSED_DIRECTIVES = [
248
+ "@ts-expect-error",
249
+ "@ts-ignore",
250
+ "biome-ignore",
251
+ "eslint-disable",
252
+ "prettier-ignore",
253
+ ] as const satisfies readonly string[];
254
+
255
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
256
+
257
+ const directiveSource = (name: string): string => {
258
+ const escaped = escapeRegExp(name);
259
+ if (name === "eslint-disable") return `\\b${escaped}[\\w-]*`;
260
+ return /^[\w]/.test(name) ? `\\b${escaped}\\b` : escaped;
261
+ };
262
+
263
+ const DIRECTIVE = new RegExp(REFUSED_DIRECTIVES.map(directiveSource).join("|"));
264
+
247
265
  type Check = {
248
266
  readonly find: RegExp;
249
267
  readonly refusal: (match: string) => string;
@@ -251,7 +269,7 @@ type Check = {
251
269
 
252
270
  const CHECKS: readonly Check[] = [
253
271
  {
254
- find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
272
+ find: DIRECTIVE,
255
273
  refusal: (match) =>
256
274
  `carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
257
275
  },
@@ -6,6 +6,8 @@ import { readQuality, renderJson } from "./quality-file.ts";
6
6
  import {
7
7
  budgetOf,
8
8
  budgetsOf,
9
+ diagnosticCode,
10
+ qualifiedName,
9
11
  SIZE_DEFAULTS,
10
12
  SIZE_RULES,
11
13
  TESTS_DIRECTORY,
@@ -68,16 +70,17 @@ const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Str
68
70
 
69
71
  function rulesOf(budget: Budget): Record<string, unknown> {
70
72
  return Object.fromEntries(
71
- SIZE_RULES.map(({ key, rule, options }) => {
72
- const max = budget[key];
73
- return [rule, max === undefined ? "off" : ["error", { max, ...options }]];
73
+ SIZE_RULES.map((entry) => {
74
+ const max = budget[entry.key];
75
+ return [qualifiedName(entry), max === undefined ? "off" : ["error", { max, ...entry.options }]];
74
76
  }),
75
77
  );
76
78
  }
77
79
 
78
- function sizeConfig({ production, tests }: Budgets): unknown {
80
+ function sizeConfig({ production, tests }: Budgets, plugin: string): unknown {
79
81
  return {
80
82
  plugins: [],
83
+ jsPlugins: [plugin],
81
84
  categories: { correctness: "off" },
82
85
  rules: rulesOf(production),
83
86
  overrides: [{ files: [`${TESTS_DIRECTORY}/**`], rules: rulesOf(tests) }],
@@ -116,7 +119,7 @@ const materializeBase = Effect.fn("materializeBase")(function* (root: string, ba
116
119
 
117
120
  const siteOf = (budgets: Budgets) =>
118
121
  Effect.fn("siteOf")(function* ({ code, message, filename, labels }: typeof Diagnostic.Type) {
119
- const rule = SIZE_RULES.find((candidate) => code === `eslint(${candidate.rule})`);
122
+ const rule = SIZE_RULES.find((candidate) => code === diagnosticCode(candidate));
120
123
  if (rule === undefined) return [];
121
124
  const measured = rule.measured.exec(message)?.[1];
122
125
  const max = budgetOf(budgets, filename)[rule.key];
@@ -135,11 +138,11 @@ const siteOf = (budgets: Budgets) =>
135
138
  ];
136
139
  });
137
140
 
138
- const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets) {
141
+ const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets, plugin: string) {
139
142
  const fs = yield* FileSystem.FileSystem;
140
143
  if (!(yield* fs.exists(tree))) return [];
141
144
  // oxlint reads an override's glob from the directory of the config that holds it, so the config sits in the tree.
142
- yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets)));
145
+ yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets, plugin)));
143
146
 
144
147
  const { stdout, stderr, exitCode } = yield* collect("oxlint", ["-c", CONFIG, "-f", "json", "."], tree).pipe(
145
148
  Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot run oxlint: ${cause.message}` })),
@@ -190,7 +193,8 @@ const runBudget = Effect.fn("runBudget")(
190
193
 
191
194
  const headTree = path.join(scratch, "head");
192
195
  if (held.length + others.length > 0) yield* materializeHead(root, head, [...holds, ...others], headTree);
193
- const sites = yield* measure(headTree, budgets);
196
+ const plugin = path.join(import.meta.dir, "..", "dist", "index.js");
197
+ const sites = yield* measure(headTree, budgets, plugin);
194
198
  const heldSites = sites.filter((site) => holds.has(site.file));
195
199
  if (applies !== "ratchet") {
196
200
  return { applies, held: held.length, overruns: heldSites, advisory: sites.filter((site) => !holds.has(site.file)) } satisfies Verdict;
@@ -198,7 +202,7 @@ const runBudget = Effect.fn("runBudget")(
198
202
 
199
203
  const baseTree = path.join(scratch, "base");
200
204
  if (held.length > 0) yield* materializeBase(root, base, held, baseTree);
201
- const growths = growthsOf(heldSites, yield* measure(baseTree, budgets));
205
+ const growths = growthsOf(heldSites, yield* measure(baseTree, budgets, plugin));
202
206
  const failing = new Set(growths.flatMap((growth) => growth.sites));
203
207
  return { applies, held: held.length, growths, advisory: sites.filter((site) => !failing.has(site)) } satisfies Verdict;
204
208
  },
@@ -30,10 +30,11 @@ const STATEMENTS = {
30
30
 
31
31
  const COMPLEXITY = {
32
32
  key: "complexity",
33
- rule: "complexity",
34
- options: { variant: "modified" },
35
- measured: /has a complexity of (\d+)/,
36
- limits: "The highest cyclomatic complexity a function may reach, a switch counted once",
33
+ rule: "cognitive-complexity",
34
+ plugin: "effect-channel",
35
+ options: {},
36
+ measured: /has a cognitive complexity of (\d+)/,
37
+ limits: "The highest cognitive complexity a function may reach, a switch counted once",
37
38
  } as const;
38
39
 
39
40
  const DEPTH = {
@@ -49,6 +50,14 @@ export const SIZE_RULES = [FILE_LINES, FUNCTION_LINES, STATEMENTS, COMPLEXITY, D
49
50
  export type SizeRule = (typeof SIZE_RULES)[number];
50
51
  export type LimitKey = SizeRule["key"];
51
52
 
53
+ export function qualifiedName(rule: SizeRule): string {
54
+ return "plugin" in rule ? `${rule.plugin}/${rule.rule}` : rule.rule;
55
+ }
56
+
57
+ export function diagnosticCode(rule: SizeRule): string {
58
+ return "plugin" in rule ? `${rule.plugin}(${rule.rule})` : `eslint(${rule.rule})`;
59
+ }
60
+
52
61
  // An absent limit turns its rule off.
53
62
  export type Budget = { readonly [K in LimitKey]?: number };
54
63