@webpieces/eslint-rules 0.0.1

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,256 @@
1
+ /**
2
+ * ESLint rule to enforce standardized catch block error handling patterns
3
+ *
4
+ * Enforces three approved patterns:
5
+ * 1. Standard: catch (err: unknown) { const error = toError(err); }
6
+ * 2. Ignored: catch (err: unknown) { //const error = toError(err); }
7
+ * 3. Nested: catch (err2: unknown) { const error2 = toError(err2); }
8
+ */
9
+
10
+ import type { Rule } from 'eslint';
11
+
12
+ // webpieces-disable no-any-unknown -- ESTree AST node interfaces require any for dynamic properties
13
+ // Using any for ESTree nodes to avoid complex type gymnastics
14
+ // ESLint rules work with dynamic AST nodes anyway
15
+ interface CatchClauseNode {
16
+ type: 'CatchClause';
17
+ param?: IdentifierNode | null;
18
+ body: BlockStatementNode;
19
+ // webpieces-disable no-any-unknown -- ESTree AST index signature
20
+ [key: string]: any;
21
+ }
22
+
23
+ interface IdentifierNode {
24
+ type: 'Identifier';
25
+ name: string;
26
+ typeAnnotation?: TypeAnnotationNode;
27
+ // webpieces-disable no-any-unknown -- ESTree AST index signature
28
+ [key: string]: any;
29
+ }
30
+
31
+ interface TypeAnnotationNode {
32
+ typeAnnotation?: {
33
+ type: string;
34
+ };
35
+ }
36
+
37
+ interface BlockStatementNode {
38
+ type: 'BlockStatement';
39
+ // webpieces-disable no-any-unknown -- ESTree AST dynamic body array
40
+ body: any[];
41
+ range: [number, number];
42
+ // webpieces-disable no-any-unknown -- ESTree AST index signature
43
+ [key: string]: any;
44
+ }
45
+
46
+ interface VariableDeclarationNode {
47
+ type: 'VariableDeclaration';
48
+ declarations: VariableDeclaratorNode[];
49
+ // webpieces-disable no-any-unknown -- ESTree AST index signature
50
+ [key: string]: any;
51
+ }
52
+
53
+ interface VariableDeclaratorNode {
54
+ type: 'VariableDeclarator';
55
+ id: IdentifierNode;
56
+ init?: CallExpressionNode | null;
57
+ // webpieces-disable no-any-unknown -- ESTree AST index signature
58
+ [key: string]: any;
59
+ }
60
+
61
+ interface CallExpressionNode {
62
+ type: 'CallExpression';
63
+ callee: IdentifierNode;
64
+ // webpieces-disable no-any-unknown -- ESTree AST dynamic arguments array
65
+ arguments: any[];
66
+ // webpieces-disable no-any-unknown -- ESTree AST index signature
67
+ [key: string]: any;
68
+ }
69
+
70
+ function validateParamName(
71
+ context: Rule.RuleContext,
72
+ param: IdentifierNode,
73
+ expectedParamName: string,
74
+ ): void {
75
+ if (param.type === 'Identifier' && param.name !== expectedParamName) {
76
+ context.report({
77
+ node: param,
78
+ messageId: 'wrongParameterName',
79
+ data: { actual: param.name },
80
+ });
81
+ }
82
+ }
83
+
84
+ function validateTypeAnnotation(
85
+ context: Rule.RuleContext,
86
+ param: IdentifierNode,
87
+ expectedParamName: string,
88
+ ): void {
89
+ if (
90
+ !param.typeAnnotation ||
91
+ !param.typeAnnotation.typeAnnotation ||
92
+ param.typeAnnotation.typeAnnotation.type !== 'TSUnknownKeyword'
93
+ ) {
94
+ context.report({
95
+ node: param,
96
+ messageId: 'missingTypeAnnotation',
97
+ data: { param: param.name || expectedParamName },
98
+ });
99
+ }
100
+ }
101
+
102
+ function hasIgnoreComment(
103
+ catchNode: CatchClauseNode,
104
+ sourceCode: Rule.RuleContext['sourceCode'],
105
+ expectedVarName: string,
106
+ actualParamName: string,
107
+ ): boolean {
108
+ const catchBlockStart = catchNode.body.range![0];
109
+ const catchBlockEnd = catchNode.body.range![1];
110
+ const catchBlockText = sourceCode.text.substring(catchBlockStart, catchBlockEnd);
111
+
112
+ const ignorePattern = new RegExp(
113
+ `//\\s*const\\s+${expectedVarName}\\s*=\\s*toError\\(${actualParamName}\\)`,
114
+ );
115
+
116
+ return ignorePattern.test(catchBlockText);
117
+ }
118
+
119
+ function reportMissingToError(
120
+ context: Rule.RuleContext,
121
+ // webpieces-disable no-any-unknown -- ESLint AST node param requires any type
122
+ node: any,
123
+ paramName: string,
124
+ ): void {
125
+ context.report({
126
+ node,
127
+ messageId: 'missingToError',
128
+ data: { param: paramName },
129
+ });
130
+ }
131
+
132
+ function validateToErrorCall(
133
+ context: Rule.RuleContext,
134
+ // webpieces-disable no-any-unknown -- ESLint AST node param requires any type
135
+ firstStatement: any,
136
+ expectedParamName: string,
137
+ expectedVarName: string,
138
+ actualParamName: string,
139
+ ): void {
140
+ if (firstStatement.type !== 'VariableDeclaration') {
141
+ reportMissingToError(context, firstStatement, expectedParamName);
142
+ return;
143
+ }
144
+
145
+ const varDecl = firstStatement as VariableDeclarationNode;
146
+ const declaration = varDecl.declarations[0];
147
+ if (!declaration) {
148
+ reportMissingToError(context, firstStatement, expectedParamName);
149
+ return;
150
+ }
151
+
152
+ if (declaration.id.type !== 'Identifier' || declaration.id.name !== expectedVarName) {
153
+ context.report({
154
+ node: declaration.id,
155
+ messageId: 'wrongVariableName',
156
+ data: { expected: expectedVarName, actual: declaration.id.name || 'unknown' },
157
+ });
158
+ return;
159
+ }
160
+
161
+ if (!declaration.init || declaration.init.type !== 'CallExpression') {
162
+ reportMissingToError(context, declaration.init || declaration, expectedParamName);
163
+ return;
164
+ }
165
+
166
+ const callExpr = declaration.init as CallExpressionNode;
167
+ const callee = callExpr.callee;
168
+ if (callee.type !== 'Identifier' || callee.name !== 'toError') {
169
+ reportMissingToError(context, callee, expectedParamName);
170
+ return;
171
+ }
172
+
173
+ const args = callExpr.arguments;
174
+ if (
175
+ args.length !== 1 ||
176
+ args[0].type !== 'Identifier' ||
177
+ (args[0] as IdentifierNode).name !== actualParamName
178
+ ) {
179
+ reportMissingToError(context, callExpr, actualParamName);
180
+ }
181
+ }
182
+
183
+ const rule: Rule.RuleModule = {
184
+ meta: {
185
+ type: 'problem',
186
+ docs: {
187
+ description: 'Enforce standardized catch block error handling patterns',
188
+ category: 'Best Practices',
189
+ recommended: true,
190
+ url: 'https://github.com/deanhiller/webpieces-ts/blob/main/claude.patterns.md#error-handling-pattern',
191
+ },
192
+ messages: {
193
+ missingToError:
194
+ 'Catch block must call toError({{param}}) as first statement or comment it out to explicitly ignore errors',
195
+ wrongVariableName: 'Error variable must be named "{{expected}}", got "{{actual}}"',
196
+ missingTypeAnnotation: 'Catch parameter must be typed as "unknown": catch ({{param}}: unknown)',
197
+ wrongParameterName:
198
+ 'Catch parameter must be named "err" (or "err2", "err3" for nested catches), got "{{actual}}"',
199
+ toErrorNotFirst: 'toError({{param}}) must be the first statement in the catch block',
200
+ },
201
+ fixable: undefined,
202
+ schema: [],
203
+ },
204
+
205
+ create(context: Rule.RuleContext): Rule.RuleListener {
206
+ const catchStack: CatchClauseNode[] = [];
207
+
208
+ return {
209
+ // webpieces-disable no-any-unknown -- ESLint visitor callback receives untyped AST node
210
+ CatchClause(node: any): void {
211
+ const catchNode = node as CatchClauseNode;
212
+ const depth = catchStack.length + 1;
213
+ catchStack.push(catchNode);
214
+
215
+ const suffix = depth === 1 ? '' : String(depth);
216
+ const expectedParamName = 'err' + suffix;
217
+ const expectedVarName = 'error' + suffix;
218
+
219
+ const param = catchNode.param;
220
+ if (!param) {
221
+ context.report({
222
+ node: catchNode,
223
+ messageId: 'missingTypeAnnotation',
224
+ data: { param: expectedParamName },
225
+ });
226
+ return;
227
+ }
228
+
229
+ const actualParamName =
230
+ param.type === 'Identifier' ? param.name : expectedParamName;
231
+
232
+ validateParamName(context, param, expectedParamName);
233
+ validateTypeAnnotation(context, param, expectedParamName);
234
+
235
+ const sourceCode = context.sourceCode || context.getSourceCode();
236
+ if (hasIgnoreComment(catchNode, sourceCode, expectedVarName, actualParamName)) {
237
+ return;
238
+ }
239
+
240
+ const body = catchNode.body.body;
241
+ if (body.length === 0) {
242
+ reportMissingToError(context, catchNode.body, expectedParamName);
243
+ return;
244
+ }
245
+
246
+ validateToErrorCall(context, body[0], expectedParamName, expectedVarName, actualParamName);
247
+ },
248
+
249
+ 'CatchClause:exit'(): void {
250
+ catchStack.pop();
251
+ },
252
+ };
253
+ },
254
+ };
255
+
256
+ export = rule;