@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.
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Validate No Unmanaged Exceptions Executor
3
+ *
4
+ * Validates that try/catch blocks are not used outside chokepoints.
5
+ * Uses LINE-BASED detection (not method-based) for git diff filtering.
6
+ *
7
+ * ============================================================================
8
+ * VIOLATIONS (BAD) - These patterns are flagged:
9
+ * ============================================================================
10
+ *
11
+ * - try { — any try/catch block in non-test code
12
+ *
13
+ * ============================================================================
14
+ * ALLOWED (skip — NOT violations)
15
+ * ============================================================================
16
+ *
17
+ * - Test files (.spec.ts, .test.ts, __tests__/)
18
+ * - Lines with // webpieces-disable no-unmanaged-exceptions -- [reason]
19
+ *
20
+ * ============================================================================
21
+ * MODES (LINE-BASED)
22
+ * ============================================================================
23
+ * - OFF: Skip validation entirely
24
+ * - MODIFIED_CODE: Flag try/catch on changed lines (lines in diff hunks)
25
+ * - MODIFIED_FILES: Flag ALL try/catch in files that were modified
26
+ *
27
+ * ============================================================================
28
+ * ESCAPE HATCH
29
+ * ============================================================================
30
+ * Add comment above the violation:
31
+ * // webpieces-disable no-unmanaged-exceptions -- [your justification]
32
+ * try {
33
+ */
34
+
35
+ import { execSync } from 'child_process';
36
+ import * as fs from 'fs';
37
+ import * as path from 'path';
38
+
39
+ export type NoUnmanagedExceptionsMode = 'OFF' | 'MODIFIED_CODE' | 'MODIFIED_FILES';
40
+
41
+ export interface ValidateNoUnmanagedExceptionsOptions {
42
+ mode?: NoUnmanagedExceptionsMode;
43
+ disableAllowed?: boolean;
44
+ ignoreModifiedUntilEpoch?: number;
45
+ }
46
+
47
+ export interface ExecutorResult {
48
+ success: boolean;
49
+ }
50
+
51
+ interface TryCatchViolation {
52
+ file: string;
53
+ line: number;
54
+ context: string;
55
+ }
56
+
57
+ /**
58
+ * Check if a file is a test file that should be skipped.
59
+ */
60
+ function isTestFile(filePath: string): boolean {
61
+ return filePath.includes('.spec.ts') ||
62
+ filePath.includes('.test.ts') ||
63
+ filePath.includes('__tests__/');
64
+ }
65
+
66
+ /**
67
+ * Get changed TypeScript files between base and head (or working tree if head not specified).
68
+ * Excludes test files.
69
+ */
70
+ // webpieces-disable max-lines-new-methods -- Git command handling with untracked files requires multiple code paths
71
+ function getChangedTypeScriptFiles(workspaceRoot: string, base: string, head?: string): string[] {
72
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
73
+ try {
74
+ const diffTarget = head ? `${base} ${head}` : base;
75
+ const output = execSync(`git diff --name-only ${diffTarget} -- '*.ts' '*.tsx'`, {
76
+ cwd: workspaceRoot,
77
+ encoding: 'utf-8',
78
+ });
79
+ const changedFiles = output
80
+ .trim()
81
+ .split('\n')
82
+ .filter((f) => f && !isTestFile(f));
83
+
84
+ if (!head) {
85
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
86
+ try {
87
+ const untrackedOutput = execSync(`git ls-files --others --exclude-standard '*.ts' '*.tsx'`, {
88
+ cwd: workspaceRoot,
89
+ encoding: 'utf-8',
90
+ });
91
+ const untrackedFiles = untrackedOutput
92
+ .trim()
93
+ .split('\n')
94
+ .filter((f) => f && !isTestFile(f));
95
+ const allFiles = new Set([...changedFiles, ...untrackedFiles]);
96
+ return Array.from(allFiles);
97
+ // webpieces-disable catch-error-pattern -- intentional swallow of git command failure
98
+ } catch {
99
+ return changedFiles;
100
+ }
101
+ }
102
+
103
+ return changedFiles;
104
+ // webpieces-disable catch-error-pattern -- intentional swallow of git command failure
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get the diff content for a specific file.
112
+ */
113
+ function getFileDiff(workspaceRoot: string, file: string, base: string, head?: string): string {
114
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
115
+ try {
116
+ const diffTarget = head ? `${base} ${head}` : base;
117
+ const diff = execSync(`git diff ${diffTarget} -- "${file}"`, {
118
+ cwd: workspaceRoot,
119
+ encoding: 'utf-8',
120
+ });
121
+
122
+ if (!diff && !head) {
123
+ const fullPath = path.join(workspaceRoot, file);
124
+ if (fs.existsSync(fullPath)) {
125
+ const isUntracked = execSync(`git ls-files --others --exclude-standard "${file}"`, {
126
+ cwd: workspaceRoot,
127
+ encoding: 'utf-8',
128
+ }).trim();
129
+
130
+ if (isUntracked) {
131
+ const content = fs.readFileSync(fullPath, 'utf-8');
132
+ const lines = content.split('\n');
133
+ return lines.map((line) => `+${line}`).join('\n');
134
+ }
135
+ }
136
+ }
137
+
138
+ return diff;
139
+ // webpieces-disable catch-error-pattern -- intentional swallow of git command failure
140
+ } catch {
141
+ return '';
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Parse diff to extract changed line numbers (additions only - lines starting with +).
147
+ */
148
+ function getChangedLineNumbers(diffContent: string): Set<number> {
149
+ const changedLines = new Set<number>();
150
+ const lines = diffContent.split('\n');
151
+ let currentLine = 0;
152
+
153
+ for (const line of lines) {
154
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
155
+ if (hunkMatch) {
156
+ currentLine = parseInt(hunkMatch[1], 10);
157
+ continue;
158
+ }
159
+
160
+ if (line.startsWith('+') && !line.startsWith('+++')) {
161
+ changedLines.add(currentLine);
162
+ currentLine++;
163
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
164
+ // Deletions don't increment line number
165
+ } else {
166
+ currentLine++;
167
+ }
168
+ }
169
+
170
+ return changedLines;
171
+ }
172
+
173
+ /**
174
+ * Check if a line contains a disable comment for no-unmanaged-exceptions.
175
+ * Recognizes both webpieces-disable and eslint-disable-next-line @webpieces/ formats.
176
+ */
177
+ function hasDisableComment(lines: string[], lineNumber: number): boolean {
178
+ const startCheck = Math.max(0, lineNumber - 5);
179
+ for (let i = lineNumber - 2; i >= startCheck; i--) {
180
+ const line = lines[i]?.trim() ?? '';
181
+ if (line.startsWith('function ') || line.startsWith('class ') || line.endsWith('}')) {
182
+ break;
183
+ }
184
+ if (line.includes('webpieces-disable') && line.includes('no-unmanaged-exceptions')) {
185
+ return true;
186
+ }
187
+ if (line.includes('@webpieces/no-unmanaged-exceptions')) {
188
+ return true;
189
+ }
190
+ }
191
+ return false;
192
+ }
193
+
194
+ const TRY_PATTERN = /\btry\s*\{/;
195
+
196
+ interface TryCatchInfo {
197
+ line: number;
198
+ context: string;
199
+ hasDisableComment: boolean;
200
+ }
201
+
202
+ /**
203
+ * Find all try/catch patterns in a file using line-based scanning.
204
+ */
205
+ function findTryCatchInFile(filePath: string, workspaceRoot: string, disableAllowed: boolean): TryCatchInfo[] {
206
+ const fullPath = path.join(workspaceRoot, filePath);
207
+ if (!fs.existsSync(fullPath)) return [];
208
+
209
+ const content = fs.readFileSync(fullPath, 'utf-8');
210
+ const fileLines = content.split('\n');
211
+ const violations: TryCatchInfo[] = [];
212
+
213
+ for (let i = 0; i < fileLines.length; i++) {
214
+ const line = fileLines[i];
215
+ const trimmed = line.trim();
216
+ // Skip comment lines (JSDoc, block comments, line comments)
217
+ if (trimmed.startsWith('*') || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;
218
+ if (!TRY_PATTERN.test(line)) continue;
219
+
220
+ const lineNum = i + 1;
221
+ const disabled = hasDisableComment(fileLines, lineNum);
222
+
223
+ if (!disableAllowed && disabled) {
224
+ violations.push({ line: lineNum, context: line.trim(), hasDisableComment: false });
225
+ } else {
226
+ violations.push({ line: lineNum, context: line.trim(), hasDisableComment: disabled });
227
+ }
228
+ }
229
+
230
+ return violations;
231
+ }
232
+
233
+ /**
234
+ * MODIFIED_CODE mode: Flag violations on changed lines in diff hunks.
235
+ */
236
+ function findViolationsForModifiedCode(
237
+ workspaceRoot: string,
238
+ changedFiles: string[],
239
+ base: string,
240
+ head: string | undefined,
241
+ disableAllowed: boolean,
242
+ ): TryCatchViolation[] {
243
+ const violations: TryCatchViolation[] = [];
244
+
245
+ for (const file of changedFiles) {
246
+ const diff = getFileDiff(workspaceRoot, file, base, head);
247
+ const changedLines = getChangedLineNumbers(diff);
248
+
249
+ if (changedLines.size === 0) continue;
250
+
251
+ const allViolations = findTryCatchInFile(file, workspaceRoot, disableAllowed);
252
+
253
+ for (const v of allViolations) {
254
+ if (disableAllowed && v.hasDisableComment) continue;
255
+ if (!changedLines.has(v.line)) continue;
256
+
257
+ violations.push({ file, line: v.line, context: v.context });
258
+ }
259
+ }
260
+
261
+ return violations;
262
+ }
263
+
264
+ /**
265
+ * MODIFIED_FILES mode: Flag ALL violations in files that were modified.
266
+ */
267
+ function findViolationsForModifiedFiles(
268
+ workspaceRoot: string,
269
+ changedFiles: string[],
270
+ disableAllowed: boolean,
271
+ ): TryCatchViolation[] {
272
+ const violations: TryCatchViolation[] = [];
273
+
274
+ for (const file of changedFiles) {
275
+ const allViolations = findTryCatchInFile(file, workspaceRoot, disableAllowed);
276
+
277
+ for (const v of allViolations) {
278
+ if (disableAllowed && v.hasDisableComment) continue;
279
+ violations.push({ file, line: v.line, context: v.context });
280
+ }
281
+ }
282
+
283
+ return violations;
284
+ }
285
+
286
+ /**
287
+ * Auto-detect the base branch by finding the merge-base with origin/main.
288
+ */
289
+ function detectBase(workspaceRoot: string): string | null {
290
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
291
+ try {
292
+ const mergeBase = execSync('git merge-base HEAD origin/main', {
293
+ cwd: workspaceRoot,
294
+ encoding: 'utf-8',
295
+ stdio: ['pipe', 'pipe', 'pipe'],
296
+ }).trim();
297
+
298
+ if (mergeBase) {
299
+ return mergeBase;
300
+ }
301
+ // webpieces-disable catch-error-pattern -- intentional swallow of git command failure
302
+ } catch {
303
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
304
+ try {
305
+ const mergeBase = execSync('git merge-base HEAD main', {
306
+ cwd: workspaceRoot,
307
+ encoding: 'utf-8',
308
+ stdio: ['pipe', 'pipe', 'pipe'],
309
+ }).trim();
310
+
311
+ if (mergeBase) {
312
+ return mergeBase;
313
+ }
314
+ // webpieces-disable catch-error-pattern -- intentional swallow of git command failure
315
+ } catch {
316
+ // Ignore
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+
322
+ /**
323
+ * Report violations to console.
324
+ */
325
+ function reportViolations(violations: TryCatchViolation[], mode: NoUnmanagedExceptionsMode, disableAllowed: boolean): void {
326
+ console.error('');
327
+ console.error('\u274c Unmanaged try/catch blocks found! Exceptions should bubble to chokepoints.');
328
+ console.error('');
329
+ console.error('\ud83d\udcda Philosophy: Most code should NOT catch exceptions.');
330
+ console.error(' Exceptions should bubble to chokepoints (filter in Node.js, globalErrorHandler in Angular)');
331
+ console.error(' where they are logged with traceId for debugging.');
332
+ console.error('');
333
+
334
+ for (const v of violations) {
335
+ console.error(` \u274c ${v.file}:${v.line}`);
336
+ console.error(` ${v.context}`);
337
+ }
338
+ console.error('');
339
+
340
+ if (disableAllowed) {
341
+ console.error(' Escape hatch (use sparingly):');
342
+ console.error(' // webpieces-disable no-unmanaged-exceptions -- [your reason]');
343
+ console.error('');
344
+ console.error(' When try/catch IS used, the catch block MUST follow:');
345
+ console.error(' catch (err: unknown) { const error = toError(err); ... }');
346
+ console.error(' or: catch (err: unknown) { //const error = toError(err); }');
347
+ } else {
348
+ console.error(' Escape hatch: DISABLED (disableAllowed: false)');
349
+ console.error(' Disable comments are ignored. Remove the try/catch.');
350
+ }
351
+ console.error('');
352
+ console.error(` Current mode: ${mode}`);
353
+ console.error('');
354
+ }
355
+
356
+ /**
357
+ * Resolve mode considering ignoreModifiedUntilEpoch override.
358
+ */
359
+ function resolveMode(normalMode: NoUnmanagedExceptionsMode, epoch: number | undefined): NoUnmanagedExceptionsMode {
360
+ if (epoch === undefined || normalMode === 'OFF') {
361
+ return normalMode;
362
+ }
363
+ const nowSeconds = Date.now() / 1000;
364
+ if (nowSeconds < epoch) {
365
+ const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];
366
+ console.log(`\n\u23ed\ufe0f Skipping no-unmanaged-exceptions validation (ignoreModifiedUntilEpoch active, expires: ${expiresDate})`);
367
+ console.log('');
368
+ return 'OFF';
369
+ }
370
+ return normalMode;
371
+ }
372
+
373
+ export default async function runValidator(
374
+ options: ValidateNoUnmanagedExceptionsOptions,
375
+ workspaceRoot: string
376
+ ): Promise<ExecutorResult> {
377
+ const mode: NoUnmanagedExceptionsMode = resolveMode(options.mode ?? 'OFF', options.ignoreModifiedUntilEpoch);
378
+ const disableAllowed = options.disableAllowed ?? true;
379
+
380
+ if (mode === 'OFF') {
381
+ console.log('\n\u23ed\ufe0f Skipping no-unmanaged-exceptions validation (mode: OFF)');
382
+ console.log('');
383
+ return { success: true };
384
+ }
385
+
386
+ console.log('\n\ud83d\udccf Validating No Unmanaged Exceptions\n');
387
+ console.log(` Mode: ${mode}`);
388
+
389
+ let base = process.env['NX_BASE'];
390
+ const head = process.env['NX_HEAD'];
391
+
392
+ if (!base) {
393
+ base = detectBase(workspaceRoot) ?? undefined;
394
+
395
+ if (!base) {
396
+ console.log('\n\u23ed\ufe0f Skipping no-unmanaged-exceptions validation (could not detect base branch)');
397
+ console.log('');
398
+ return { success: true };
399
+ }
400
+ }
401
+
402
+ console.log(` Base: ${base}`);
403
+ console.log(` Head: ${head ?? 'working tree (includes uncommitted changes)'}`);
404
+ console.log('');
405
+
406
+ const changedFiles = getChangedTypeScriptFiles(workspaceRoot, base, head);
407
+
408
+ if (changedFiles.length === 0) {
409
+ console.log('\u2705 No TypeScript files changed');
410
+ return { success: true };
411
+ }
412
+
413
+ console.log(`\ud83d\udcc2 Checking ${changedFiles.length} changed file(s)...`);
414
+
415
+ let violations: TryCatchViolation[] = [];
416
+
417
+ if (mode === 'MODIFIED_CODE') {
418
+ violations = findViolationsForModifiedCode(workspaceRoot, changedFiles, base, head, disableAllowed);
419
+ } else if (mode === 'MODIFIED_FILES') {
420
+ violations = findViolationsForModifiedFiles(workspaceRoot, changedFiles, disableAllowed);
421
+ }
422
+
423
+ if (violations.length === 0) {
424
+ console.log('\u2705 No unmanaged try/catch blocks found');
425
+ return { success: true };
426
+ }
427
+
428
+ reportViolations(violations, mode, disableAllowed);
429
+
430
+ return { success: false };
431
+ }