@webpieces/code-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.
- package/LICENSE +373 -0
- package/jest.config.ts +20 -0
- package/package.json +23 -0
- package/project.json +22 -0
- package/src/cli.ts +17 -0
- package/src/diff-utils.ts +129 -0
- package/src/from-shared-config.ts +118 -0
- package/src/index.ts +14 -0
- package/src/validate-catch-error-pattern.ts +639 -0
- package/src/validate-code.ts +491 -0
- package/src/validate-dtos.ts +697 -0
- package/src/validate-modified-files.ts +579 -0
- package/src/validate-modified-methods.ts +812 -0
- package/src/validate-new-methods.ts +594 -0
- package/src/validate-no-any-unknown.ts +552 -0
- package/src/validate-no-destructure.ts +588 -0
- package/src/validate-no-direct-api-resolver.ts +676 -0
- package/src/validate-no-implicit-any.ts +378 -0
- package/src/validate-no-inline-types.ts +787 -0
- package/src/validate-no-unmanaged-exceptions.ts +431 -0
- package/src/validate-prisma-converters.ts +830 -0
- package/src/validate-return-types.ts +532 -0
- package/tsconfig.json +22 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.spec.json +14 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate Catch Error Pattern Executor
|
|
3
|
+
*
|
|
4
|
+
* Validates that catch blocks follow the standardized error handling pattern.
|
|
5
|
+
* Uses TypeScript AST for detection and LINE-BASED git diff filtering.
|
|
6
|
+
*
|
|
7
|
+
* ============================================================================
|
|
8
|
+
* REQUIRED PATTERN
|
|
9
|
+
* ============================================================================
|
|
10
|
+
*
|
|
11
|
+
* Standard: catch (err: unknown) { const error = toError(err); ... }
|
|
12
|
+
* Ignored: catch (err: unknown) { //const error = toError(err); ... }
|
|
13
|
+
* Nested: catch (err2: unknown) { const error2 = toError(err2); ... }
|
|
14
|
+
*
|
|
15
|
+
* ============================================================================
|
|
16
|
+
* VIOLATIONS (BAD) - These patterns are flagged:
|
|
17
|
+
* ============================================================================
|
|
18
|
+
*
|
|
19
|
+
* - catch (e) { ... } — wrong parameter name
|
|
20
|
+
* - catch (err) { ... } — missing : unknown type annotation
|
|
21
|
+
* - catch (err: unknown) { ... } — missing toError() as first statement
|
|
22
|
+
* - catch (err: unknown) { const x = toError(err); } — wrong variable name
|
|
23
|
+
*
|
|
24
|
+
* ============================================================================
|
|
25
|
+
* MODES (LINE-BASED)
|
|
26
|
+
* ============================================================================
|
|
27
|
+
* - OFF: Skip validation entirely
|
|
28
|
+
* - MODIFIED_CODE: Flag catch violations on changed lines (lines in diff hunks)
|
|
29
|
+
* - MODIFIED_FILES: Flag ALL catch violations in files that were modified
|
|
30
|
+
*
|
|
31
|
+
* ============================================================================
|
|
32
|
+
* ESCAPE HATCH
|
|
33
|
+
* ============================================================================
|
|
34
|
+
* Add comment above the violation:
|
|
35
|
+
* // webpieces-disable catch-error-pattern -- [your justification]
|
|
36
|
+
* } catch (err: unknown) {
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { execSync } from 'child_process';
|
|
40
|
+
import * as fs from 'fs';
|
|
41
|
+
import * as path from 'path';
|
|
42
|
+
import * as ts from 'typescript';
|
|
43
|
+
|
|
44
|
+
export type CatchErrorPatternMode = 'OFF' | 'MODIFIED_CODE' | 'MODIFIED_FILES';
|
|
45
|
+
|
|
46
|
+
export interface ValidateCatchErrorPatternOptions {
|
|
47
|
+
mode?: CatchErrorPatternMode;
|
|
48
|
+
disableAllowed?: boolean;
|
|
49
|
+
ignoreModifiedUntilEpoch?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ExecutorResult {
|
|
53
|
+
success: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CatchViolation {
|
|
57
|
+
file: string;
|
|
58
|
+
line: number;
|
|
59
|
+
message: string;
|
|
60
|
+
context: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface CatchViolationInfo {
|
|
64
|
+
line: number;
|
|
65
|
+
message: string;
|
|
66
|
+
context: string;
|
|
67
|
+
hasDisableComment: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a file is a test file that should be skipped.
|
|
72
|
+
*/
|
|
73
|
+
function isTestFile(filePath: string): boolean {
|
|
74
|
+
return filePath.includes('.spec.ts') ||
|
|
75
|
+
filePath.includes('.test.ts') ||
|
|
76
|
+
filePath.includes('__tests__/');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get changed TypeScript files between base and head.
|
|
81
|
+
* Excludes test files.
|
|
82
|
+
*/
|
|
83
|
+
// webpieces-disable max-lines-new-methods -- Git command handling with untracked files requires multiple code paths
|
|
84
|
+
function getChangedTypeScriptFiles(workspaceRoot: string, base: string, head?: string): string[] {
|
|
85
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
86
|
+
try {
|
|
87
|
+
const diffTarget = head ? `${base} ${head}` : base;
|
|
88
|
+
const output = execSync(`git diff --name-only ${diffTarget} -- '*.ts' '*.tsx'`, {
|
|
89
|
+
cwd: workspaceRoot,
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
});
|
|
92
|
+
const changedFiles = output
|
|
93
|
+
.trim()
|
|
94
|
+
.split('\n')
|
|
95
|
+
.filter((f) => f && !isTestFile(f));
|
|
96
|
+
|
|
97
|
+
if (!head) {
|
|
98
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
99
|
+
try {
|
|
100
|
+
const untrackedOutput = execSync(`git ls-files --others --exclude-standard '*.ts' '*.tsx'`, {
|
|
101
|
+
cwd: workspaceRoot,
|
|
102
|
+
encoding: 'utf-8',
|
|
103
|
+
});
|
|
104
|
+
const untrackedFiles = untrackedOutput
|
|
105
|
+
.trim()
|
|
106
|
+
.split('\n')
|
|
107
|
+
.filter((f) => f && !isTestFile(f));
|
|
108
|
+
const allFiles = new Set([...changedFiles, ...untrackedFiles]);
|
|
109
|
+
return Array.from(allFiles);
|
|
110
|
+
// webpieces-disable catch-error-pattern -- intentional swallow of git command failure
|
|
111
|
+
} catch {
|
|
112
|
+
return changedFiles;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return changedFiles;
|
|
117
|
+
// webpieces-disable catch-error-pattern -- intentional swallow of git command failure
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get the diff content for a specific file.
|
|
125
|
+
*/
|
|
126
|
+
function getFileDiff(workspaceRoot: string, file: string, base: string, head?: string): string {
|
|
127
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
128
|
+
try {
|
|
129
|
+
const diffTarget = head ? `${base} ${head}` : base;
|
|
130
|
+
const diff = execSync(`git diff ${diffTarget} -- "${file}"`, {
|
|
131
|
+
cwd: workspaceRoot,
|
|
132
|
+
encoding: 'utf-8',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!diff && !head) {
|
|
136
|
+
const fullPath = path.join(workspaceRoot, file);
|
|
137
|
+
if (fs.existsSync(fullPath)) {
|
|
138
|
+
const isUntracked = execSync(`git ls-files --others --exclude-standard "${file}"`, {
|
|
139
|
+
cwd: workspaceRoot,
|
|
140
|
+
encoding: 'utf-8',
|
|
141
|
+
}).trim();
|
|
142
|
+
|
|
143
|
+
if (isUntracked) {
|
|
144
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
145
|
+
const lines = content.split('\n');
|
|
146
|
+
return lines.map((line) => `+${line}`).join('\n');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return diff;
|
|
152
|
+
// webpieces-disable catch-error-pattern -- intentional swallow of git command failure
|
|
153
|
+
} catch {
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parse diff to extract changed line numbers (additions only).
|
|
160
|
+
*/
|
|
161
|
+
function getChangedLineNumbers(diffContent: string): Set<number> {
|
|
162
|
+
const changedLines = new Set<number>();
|
|
163
|
+
const lines = diffContent.split('\n');
|
|
164
|
+
let currentLine = 0;
|
|
165
|
+
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
168
|
+
if (hunkMatch) {
|
|
169
|
+
currentLine = parseInt(hunkMatch[1], 10);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
174
|
+
changedLines.add(currentLine);
|
|
175
|
+
currentLine++;
|
|
176
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
177
|
+
// Deletions don't increment line number
|
|
178
|
+
} else {
|
|
179
|
+
currentLine++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return changedLines;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if a line contains a disable comment for catch-error-pattern.
|
|
188
|
+
* Recognizes both webpieces-disable and eslint-disable-next-line @webpieces/ formats.
|
|
189
|
+
*/
|
|
190
|
+
function hasDisableComment(lines: string[], lineNumber: number): boolean {
|
|
191
|
+
const startCheck = Math.max(0, lineNumber - 5);
|
|
192
|
+
for (let i = lineNumber - 2; i >= startCheck; i--) {
|
|
193
|
+
const line = lines[i]?.trim() ?? '';
|
|
194
|
+
if (line.startsWith('function ') || line.startsWith('class ') || line.endsWith('}')) {
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (line.includes('webpieces-disable') && line.includes('catch-error-pattern')) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
if (line.includes('@webpieces/catch-error-pattern')) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Check if the catch block contains a disable comment for catch-error-pattern.
|
|
209
|
+
*/
|
|
210
|
+
function hasBlockLevelDisable(sourceText: string, blockStart: number, blockEnd: number): boolean {
|
|
211
|
+
const blockText = sourceText.substring(blockStart, blockEnd);
|
|
212
|
+
return blockText.includes('webpieces-disable') && blockText.includes('catch-error-pattern') ||
|
|
213
|
+
blockText.includes('@webpieces/catch-error-pattern');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if the catch block body text contains the commented-out ignore pattern.
|
|
218
|
+
*/
|
|
219
|
+
function hasIgnoreComment(
|
|
220
|
+
sourceText: string,
|
|
221
|
+
blockStart: number,
|
|
222
|
+
blockEnd: number,
|
|
223
|
+
expectedVarName: string,
|
|
224
|
+
actualParamName: string,
|
|
225
|
+
): boolean {
|
|
226
|
+
const blockText = sourceText.substring(blockStart, blockEnd);
|
|
227
|
+
const ignorePattern = new RegExp(
|
|
228
|
+
`//\\s*const\\s+${expectedVarName}\\s*=\\s*toError\\(${actualParamName}\\)`,
|
|
229
|
+
);
|
|
230
|
+
return ignorePattern.test(blockText);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validate a single CatchClause node.
|
|
235
|
+
*/
|
|
236
|
+
// webpieces-disable max-lines-new-methods -- AST validation with multiple check paths for param name, type, and toError
|
|
237
|
+
function validateCatchClause(
|
|
238
|
+
node: ts.CatchClause,
|
|
239
|
+
sourceFile: ts.SourceFile,
|
|
240
|
+
fileLines: string[],
|
|
241
|
+
depth: number,
|
|
242
|
+
disableAllowed: boolean,
|
|
243
|
+
): CatchViolationInfo[] {
|
|
244
|
+
const violations: CatchViolationInfo[] = [];
|
|
245
|
+
const suffix = depth === 1 ? '' : String(depth);
|
|
246
|
+
const expectedParam = 'err' + suffix;
|
|
247
|
+
const expectedVar = 'error' + suffix;
|
|
248
|
+
|
|
249
|
+
const catchLine = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
250
|
+
const catchContext = fileLines[catchLine - 1]?.trim() ?? '';
|
|
251
|
+
const blockStart = node.block.getStart(sourceFile);
|
|
252
|
+
const blockEnd = node.block.getEnd();
|
|
253
|
+
const disabled = hasDisableComment(fileLines, catchLine) ||
|
|
254
|
+
hasBlockLevelDisable(sourceFile.text, blockStart, blockEnd);
|
|
255
|
+
|
|
256
|
+
const varDecl = node.variableDeclaration;
|
|
257
|
+
if (!varDecl) {
|
|
258
|
+
violations.push({
|
|
259
|
+
line: catchLine, context: catchContext,
|
|
260
|
+
message: `Catch clause must declare a parameter: catch (${expectedParam}: unknown)`,
|
|
261
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
262
|
+
});
|
|
263
|
+
return violations;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const actualParam = ts.isIdentifier(varDecl.name) ? varDecl.name.text : expectedParam;
|
|
267
|
+
|
|
268
|
+
// Check parameter name
|
|
269
|
+
if (ts.isIdentifier(varDecl.name) && varDecl.name.text !== expectedParam) {
|
|
270
|
+
violations.push({
|
|
271
|
+
line: catchLine, context: catchContext,
|
|
272
|
+
message: `Catch parameter must be named "${expectedParam}" (or "err2", "err3" for nested), got "${varDecl.name.text}"`,
|
|
273
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Check type annotation is : unknown
|
|
278
|
+
if (!varDecl.type || varDecl.type.kind !== ts.SyntaxKind.UnknownKeyword) {
|
|
279
|
+
violations.push({
|
|
280
|
+
line: catchLine, context: catchContext,
|
|
281
|
+
message: `Catch parameter must be typed as "unknown": catch (${actualParam}: unknown)`,
|
|
282
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check for commented-out ignore pattern
|
|
287
|
+
if (hasIgnoreComment(sourceFile.text, blockStart, blockEnd, expectedVar, actualParam)) {
|
|
288
|
+
return violations;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Check first statement is const error = toError(err)
|
|
292
|
+
if (node.block.statements.length === 0) {
|
|
293
|
+
violations.push({
|
|
294
|
+
line: catchLine, context: catchContext,
|
|
295
|
+
message: `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam});`,
|
|
296
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
297
|
+
});
|
|
298
|
+
return violations;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const firstStmt = node.block.statements[0];
|
|
302
|
+
const toErrorViolation = validateToErrorStatement(firstStmt, sourceFile, fileLines, expectedParam, expectedVar, actualParam, disabled, disableAllowed);
|
|
303
|
+
if (toErrorViolation) {
|
|
304
|
+
violations.push(toErrorViolation);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return violations;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resolveDisable(disabled: boolean, disableAllowed: boolean): boolean {
|
|
311
|
+
if (!disableAllowed && disabled) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
return disabled;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Validate that the first statement is `const error = toError(err);`
|
|
319
|
+
*/
|
|
320
|
+
// webpieces-disable max-lines-new-methods -- Deep AST check for variable declaration with toError call expression
|
|
321
|
+
function validateToErrorStatement(
|
|
322
|
+
stmt: ts.Statement,
|
|
323
|
+
sourceFile: ts.SourceFile,
|
|
324
|
+
fileLines: string[],
|
|
325
|
+
expectedParam: string,
|
|
326
|
+
expectedVar: string,
|
|
327
|
+
actualParam: string,
|
|
328
|
+
disabled: boolean,
|
|
329
|
+
disableAllowed: boolean,
|
|
330
|
+
): CatchViolationInfo | null {
|
|
331
|
+
const stmtLine = sourceFile.getLineAndCharacterOfPosition(stmt.getStart(sourceFile)).line + 1;
|
|
332
|
+
const stmtContext = fileLines[stmtLine - 1]?.trim() ?? '';
|
|
333
|
+
|
|
334
|
+
if (!ts.isVariableStatement(stmt)) {
|
|
335
|
+
return {
|
|
336
|
+
line: stmtLine,
|
|
337
|
+
message: `First statement in catch must be: const ${expectedVar} = toError(${actualParam});`,
|
|
338
|
+
context: stmtContext,
|
|
339
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const declarations = stmt.declarationList.declarations;
|
|
344
|
+
if (declarations.length === 0) {
|
|
345
|
+
return {
|
|
346
|
+
line: stmtLine,
|
|
347
|
+
message: `First statement in catch must be: const ${expectedVar} = toError(${actualParam});`,
|
|
348
|
+
context: stmtContext,
|
|
349
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const decl = declarations[0];
|
|
354
|
+
|
|
355
|
+
// Check variable name
|
|
356
|
+
if (ts.isIdentifier(decl.name) && decl.name.text !== expectedVar) {
|
|
357
|
+
return {
|
|
358
|
+
line: stmtLine,
|
|
359
|
+
message: `Error variable must be named "${expectedVar}", got "${decl.name.text}"`,
|
|
360
|
+
context: stmtContext,
|
|
361
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check initializer is toError(actualParam)
|
|
366
|
+
const init = decl.initializer;
|
|
367
|
+
if (!init || !ts.isCallExpression(init)) {
|
|
368
|
+
return {
|
|
369
|
+
line: stmtLine,
|
|
370
|
+
message: `First statement in catch must be: const ${expectedVar} = toError(${actualParam});`,
|
|
371
|
+
context: stmtContext,
|
|
372
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!ts.isIdentifier(init.expression) || init.expression.text !== 'toError') {
|
|
377
|
+
return {
|
|
378
|
+
line: stmtLine,
|
|
379
|
+
message: `First statement in catch must call toError(), not "${init.expression.getText(sourceFile)}"`,
|
|
380
|
+
context: stmtContext,
|
|
381
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Check argument
|
|
386
|
+
const args = init.arguments;
|
|
387
|
+
if (args.length !== 1 || !ts.isIdentifier(args[0]) || args[0].text !== actualParam) {
|
|
388
|
+
return {
|
|
389
|
+
line: stmtLine,
|
|
390
|
+
message: `toError() must be called with "${actualParam}"`,
|
|
391
|
+
context: stmtContext,
|
|
392
|
+
hasDisableComment: resolveDisable(disabled, disableAllowed),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Find all catch pattern violations in a file using AST.
|
|
401
|
+
*/
|
|
402
|
+
function findCatchViolationsInFile(
|
|
403
|
+
filePath: string,
|
|
404
|
+
workspaceRoot: string,
|
|
405
|
+
disableAllowed: boolean,
|
|
406
|
+
): CatchViolationInfo[] {
|
|
407
|
+
const fullPath = path.join(workspaceRoot, filePath);
|
|
408
|
+
if (!fs.existsSync(fullPath)) return [];
|
|
409
|
+
|
|
410
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
411
|
+
const fileLines = content.split('\n');
|
|
412
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
413
|
+
|
|
414
|
+
const violations: CatchViolationInfo[] = [];
|
|
415
|
+
let catchDepth = 0;
|
|
416
|
+
|
|
417
|
+
function visit(node: ts.Node): void {
|
|
418
|
+
if (ts.isCatchClause(node)) {
|
|
419
|
+
catchDepth++;
|
|
420
|
+
const clauseViolations = validateCatchClause(node, sourceFile, fileLines, catchDepth, disableAllowed);
|
|
421
|
+
violations.push(...clauseViolations);
|
|
422
|
+
ts.forEachChild(node, visit);
|
|
423
|
+
catchDepth--;
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
ts.forEachChild(node, visit);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
visit(sourceFile);
|
|
430
|
+
return violations;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* MODIFIED_CODE mode: Flag violations on changed lines.
|
|
435
|
+
*/
|
|
436
|
+
function findViolationsForModifiedCode(
|
|
437
|
+
workspaceRoot: string,
|
|
438
|
+
changedFiles: string[],
|
|
439
|
+
base: string,
|
|
440
|
+
head: string | undefined,
|
|
441
|
+
disableAllowed: boolean,
|
|
442
|
+
): CatchViolation[] {
|
|
443
|
+
const violations: CatchViolation[] = [];
|
|
444
|
+
|
|
445
|
+
for (const file of changedFiles) {
|
|
446
|
+
const diff = getFileDiff(workspaceRoot, file, base, head);
|
|
447
|
+
const changedLines = getChangedLineNumbers(diff);
|
|
448
|
+
|
|
449
|
+
if (changedLines.size === 0) continue;
|
|
450
|
+
|
|
451
|
+
const allViolations = findCatchViolationsInFile(file, workspaceRoot, disableAllowed);
|
|
452
|
+
|
|
453
|
+
for (const v of allViolations) {
|
|
454
|
+
if (disableAllowed && v.hasDisableComment) continue;
|
|
455
|
+
if (!changedLines.has(v.line)) continue;
|
|
456
|
+
|
|
457
|
+
violations.push({ file, line: v.line, message: v.message, context: v.context });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return violations;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* MODIFIED_FILES mode: Flag ALL violations in modified files.
|
|
466
|
+
*/
|
|
467
|
+
function findViolationsForModifiedFiles(
|
|
468
|
+
workspaceRoot: string,
|
|
469
|
+
changedFiles: string[],
|
|
470
|
+
disableAllowed: boolean,
|
|
471
|
+
): CatchViolation[] {
|
|
472
|
+
const violations: CatchViolation[] = [];
|
|
473
|
+
|
|
474
|
+
for (const file of changedFiles) {
|
|
475
|
+
const allViolations = findCatchViolationsInFile(file, workspaceRoot, disableAllowed);
|
|
476
|
+
|
|
477
|
+
for (const v of allViolations) {
|
|
478
|
+
if (disableAllowed && v.hasDisableComment) continue;
|
|
479
|
+
violations.push({ file, line: v.line, message: v.message, context: v.context });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return violations;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Auto-detect the base branch by finding the merge-base with origin/main.
|
|
488
|
+
*/
|
|
489
|
+
function detectBase(workspaceRoot: string): string | null {
|
|
490
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
491
|
+
try {
|
|
492
|
+
const mergeBase = execSync('git merge-base HEAD origin/main', {
|
|
493
|
+
cwd: workspaceRoot,
|
|
494
|
+
encoding: 'utf-8',
|
|
495
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
496
|
+
}).trim();
|
|
497
|
+
|
|
498
|
+
if (mergeBase) {
|
|
499
|
+
return mergeBase;
|
|
500
|
+
}
|
|
501
|
+
// webpieces-disable catch-error-pattern -- intentional swallow of git command failure
|
|
502
|
+
} catch {
|
|
503
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
504
|
+
try {
|
|
505
|
+
const mergeBase = execSync('git merge-base HEAD main', {
|
|
506
|
+
cwd: workspaceRoot,
|
|
507
|
+
encoding: 'utf-8',
|
|
508
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
509
|
+
}).trim();
|
|
510
|
+
|
|
511
|
+
if (mergeBase) {
|
|
512
|
+
return mergeBase;
|
|
513
|
+
}
|
|
514
|
+
// webpieces-disable catch-error-pattern -- intentional swallow of git command failure
|
|
515
|
+
} catch {
|
|
516
|
+
// Ignore
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Report violations to console.
|
|
524
|
+
*/
|
|
525
|
+
// webpieces-disable max-lines-new-methods -- Console output with pattern examples and escape hatch
|
|
526
|
+
function reportViolations(violations: CatchViolation[], mode: CatchErrorPatternMode, disableAllowed: boolean): void {
|
|
527
|
+
console.error('');
|
|
528
|
+
console.error('\u274c Catch blocks must follow the standardized error handling pattern!');
|
|
529
|
+
console.error('');
|
|
530
|
+
console.error('\ud83d\udcda Required pattern:');
|
|
531
|
+
console.error('');
|
|
532
|
+
console.error(' catch (err: unknown) {');
|
|
533
|
+
console.error(' const error = toError(err);');
|
|
534
|
+
console.error(' // ... use error ...');
|
|
535
|
+
console.error(' }');
|
|
536
|
+
console.error('');
|
|
537
|
+
console.error(' Or to explicitly ignore:');
|
|
538
|
+
console.error(' catch (err: unknown) {');
|
|
539
|
+
console.error(' //const error = toError(err);');
|
|
540
|
+
console.error(' }');
|
|
541
|
+
console.error('');
|
|
542
|
+
console.error(' For nested catches: err2/error2, err3/error3, etc.');
|
|
543
|
+
console.error('');
|
|
544
|
+
|
|
545
|
+
for (const v of violations) {
|
|
546
|
+
console.error(` \u274c ${v.file}:${v.line}`);
|
|
547
|
+
console.error(` ${v.message}`);
|
|
548
|
+
console.error(` ${v.context}`);
|
|
549
|
+
}
|
|
550
|
+
console.error('');
|
|
551
|
+
|
|
552
|
+
if (disableAllowed) {
|
|
553
|
+
console.error(' Escape hatch (use sparingly):');
|
|
554
|
+
console.error(' // webpieces-disable catch-error-pattern -- [your reason]');
|
|
555
|
+
} else {
|
|
556
|
+
console.error(' Escape hatch: DISABLED (disableAllowed: false)');
|
|
557
|
+
console.error(' Disable comments are ignored. Fix the catch block directly.');
|
|
558
|
+
}
|
|
559
|
+
console.error('');
|
|
560
|
+
console.error(` Current mode: ${mode}`);
|
|
561
|
+
console.error('');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Resolve mode considering ignoreModifiedUntilEpoch override.
|
|
566
|
+
*/
|
|
567
|
+
function resolveMode(normalMode: CatchErrorPatternMode, epoch: number | undefined): CatchErrorPatternMode {
|
|
568
|
+
if (epoch === undefined || normalMode === 'OFF') {
|
|
569
|
+
return normalMode;
|
|
570
|
+
}
|
|
571
|
+
const nowSeconds = Date.now() / 1000;
|
|
572
|
+
if (nowSeconds < epoch) {
|
|
573
|
+
const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];
|
|
574
|
+
console.log(`\n\u23ed\ufe0f Skipping catch-error-pattern validation (ignoreModifiedUntilEpoch active, expires: ${expiresDate})`);
|
|
575
|
+
console.log('');
|
|
576
|
+
return 'OFF';
|
|
577
|
+
}
|
|
578
|
+
return normalMode;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export default async function runValidator(
|
|
582
|
+
options: ValidateCatchErrorPatternOptions,
|
|
583
|
+
workspaceRoot: string
|
|
584
|
+
): Promise<ExecutorResult> {
|
|
585
|
+
const mode: CatchErrorPatternMode = resolveMode(options.mode ?? 'OFF', options.ignoreModifiedUntilEpoch);
|
|
586
|
+
const disableAllowed = options.disableAllowed ?? true;
|
|
587
|
+
|
|
588
|
+
if (mode === 'OFF') {
|
|
589
|
+
console.log('\n\u23ed\ufe0f Skipping catch-error-pattern validation (mode: OFF)');
|
|
590
|
+
console.log('');
|
|
591
|
+
return { success: true };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
console.log('\n\ud83d\udccf Validating Catch Error Pattern\n');
|
|
595
|
+
console.log(` Mode: ${mode}`);
|
|
596
|
+
|
|
597
|
+
let base = process.env['NX_BASE'];
|
|
598
|
+
const head = process.env['NX_HEAD'];
|
|
599
|
+
|
|
600
|
+
if (!base) {
|
|
601
|
+
base = detectBase(workspaceRoot) ?? undefined;
|
|
602
|
+
|
|
603
|
+
if (!base) {
|
|
604
|
+
console.log('\n\u23ed\ufe0f Skipping catch-error-pattern validation (could not detect base branch)');
|
|
605
|
+
console.log('');
|
|
606
|
+
return { success: true };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
console.log(` Base: ${base}`);
|
|
611
|
+
console.log(` Head: ${head ?? 'working tree (includes uncommitted changes)'}`);
|
|
612
|
+
console.log('');
|
|
613
|
+
|
|
614
|
+
const changedFiles = getChangedTypeScriptFiles(workspaceRoot, base, head);
|
|
615
|
+
|
|
616
|
+
if (changedFiles.length === 0) {
|
|
617
|
+
console.log('\u2705 No TypeScript files changed');
|
|
618
|
+
return { success: true };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
console.log(`\ud83d\udcc2 Checking ${changedFiles.length} changed file(s)...`);
|
|
622
|
+
|
|
623
|
+
let violations: CatchViolation[] = [];
|
|
624
|
+
|
|
625
|
+
if (mode === 'MODIFIED_CODE') {
|
|
626
|
+
violations = findViolationsForModifiedCode(workspaceRoot, changedFiles, base, head, disableAllowed);
|
|
627
|
+
} else if (mode === 'MODIFIED_FILES') {
|
|
628
|
+
violations = findViolationsForModifiedFiles(workspaceRoot, changedFiles, disableAllowed);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (violations.length === 0) {
|
|
632
|
+
console.log('\u2705 No catch error pattern violations found');
|
|
633
|
+
return { success: true };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
reportViolations(violations, mode, disableAllowed);
|
|
637
|
+
|
|
638
|
+
return { success: false };
|
|
639
|
+
}
|