@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,532 @@
1
+ /**
2
+ * Validate Return Types Executor
3
+ *
4
+ * Validates that methods have explicit return type annotations for better code readability.
5
+ * Instead of relying on TypeScript's type inference, explicit return types make code clearer:
6
+ *
7
+ * BAD: method() { return new MyClass(); }
8
+ * GOOD: method(): MyClass { return new MyClass(); }
9
+ * GOOD: async method(): Promise<MyType> { ... }
10
+ *
11
+ * Modes:
12
+ * - OFF: Skip validation entirely
13
+ * - NEW_METHODS: Only validate new methods (detected via git diff)
14
+ * - NEW_AND_MODIFIED_METHODS: Validate new methods + methods with changes in their line range
15
+ * - MODIFIED_FILES: Validate all methods in modified files
16
+ *
17
+ * Escape hatch: Add webpieces-disable require-return-type comment with justification
18
+ */
19
+
20
+ import { execSync } from 'child_process';
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ import * as ts from 'typescript';
24
+
25
+ export type ReturnTypeMode = 'OFF' | 'NEW_METHODS' | 'NEW_AND_MODIFIED_METHODS' | 'MODIFIED_FILES';
26
+
27
+ export interface ValidateReturnTypesOptions {
28
+ mode?: ReturnTypeMode;
29
+ disableAllowed?: boolean;
30
+ ignoreModifiedUntilEpoch?: number;
31
+ }
32
+
33
+ export interface ExecutorResult {
34
+ success: boolean;
35
+ }
36
+
37
+ interface MethodViolation {
38
+ file: string;
39
+ methodName: string;
40
+ line: number;
41
+ }
42
+
43
+ /**
44
+ * Get changed TypeScript files between base and head (or working tree if head not specified).
45
+ */
46
+ // webpieces-disable max-lines-new-methods -- Git command handling with untracked files requires multiple code paths
47
+ function getChangedTypeScriptFiles(workspaceRoot: string, base: string, head?: string): string[] {
48
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
49
+ try {
50
+ const diffTarget = head ? `${base} ${head}` : base;
51
+ const output = execSync(`git diff --name-only ${diffTarget} -- '*.ts' '*.tsx'`, {
52
+ cwd: workspaceRoot,
53
+ encoding: 'utf-8',
54
+ });
55
+ const changedFiles = output
56
+ .trim()
57
+ .split('\n')
58
+ .filter((f) => f && !f.includes('.spec.ts') && !f.includes('.test.ts'));
59
+
60
+ if (!head) {
61
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
62
+ try {
63
+ const untrackedOutput = execSync(`git ls-files --others --exclude-standard '*.ts' '*.tsx'`, {
64
+ cwd: workspaceRoot,
65
+ encoding: 'utf-8',
66
+ });
67
+ const untrackedFiles = untrackedOutput
68
+ .trim()
69
+ .split('\n')
70
+ .filter((f) => f && !f.includes('.spec.ts') && !f.includes('.test.ts'));
71
+ const allFiles = new Set([...changedFiles, ...untrackedFiles]);
72
+ return Array.from(allFiles);
73
+ } catch (err: unknown) {
74
+ //const error = toError(err);
75
+ return changedFiles;
76
+ }
77
+ }
78
+
79
+ return changedFiles;
80
+ } catch (err: unknown) {
81
+ //const error = toError(err);
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get the diff content for a specific file.
88
+ */
89
+ function getFileDiff(workspaceRoot: string, file: string, base: string, head?: string): string {
90
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
91
+ try {
92
+ const diffTarget = head ? `${base} ${head}` : base;
93
+ const diff = execSync(`git diff ${diffTarget} -- "${file}"`, {
94
+ cwd: workspaceRoot,
95
+ encoding: 'utf-8',
96
+ });
97
+
98
+ if (!diff && !head) {
99
+ const fullPath = path.join(workspaceRoot, file);
100
+ if (fs.existsSync(fullPath)) {
101
+ const isUntracked = execSync(`git ls-files --others --exclude-standard "${file}"`, {
102
+ cwd: workspaceRoot,
103
+ encoding: 'utf-8',
104
+ }).trim();
105
+
106
+ if (isUntracked) {
107
+ const content = fs.readFileSync(fullPath, 'utf-8');
108
+ const lines = content.split('\n');
109
+ return lines.map((line) => `+${line}`).join('\n');
110
+ }
111
+ }
112
+ }
113
+
114
+ return diff;
115
+ } catch (err: unknown) {
116
+ //const error = toError(err);
117
+ return '';
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Parse diff to find newly added method signatures.
123
+ */
124
+ function findNewMethodSignaturesInDiff(diffContent: string): Set<string> {
125
+ const newMethods = new Set<string>();
126
+ const lines = diffContent.split('\n');
127
+
128
+ const patterns = [
129
+ /^\+\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/,
130
+ /^\+\s*(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/,
131
+ /^\+\s*(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?function/,
132
+ /^\+\s*(?:(?:public|private|protected)\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\(/,
133
+ ];
134
+
135
+ for (const line of lines) {
136
+ if (line.startsWith('+') && !line.startsWith('+++')) {
137
+ for (const pattern of patterns) {
138
+ const match = line.match(pattern);
139
+ if (match) {
140
+ const methodName = match[1];
141
+ if (methodName && !['if', 'for', 'while', 'switch', 'catch', 'constructor'].includes(methodName)) {
142
+ newMethods.add(methodName);
143
+ }
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return newMethods;
151
+ }
152
+
153
+ /**
154
+ * Check if a line contains a webpieces-disable comment for return type.
155
+ */
156
+ function hasDisableComment(lines: string[], lineNumber: number): boolean {
157
+ const startCheck = Math.max(0, lineNumber - 5);
158
+ for (let i = lineNumber - 2; i >= startCheck; i--) {
159
+ const line = lines[i]?.trim() ?? '';
160
+ if (line.startsWith('function ') || line.startsWith('class ') || line.endsWith('}')) {
161
+ break;
162
+ }
163
+ if (line.includes('webpieces-disable') && line.includes('require-return-type')) {
164
+ return true;
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * Check if a method has an explicit return type annotation.
172
+ */
173
+ function hasExplicitReturnType(node: ts.MethodDeclaration | ts.FunctionDeclaration | ts.ArrowFunction): boolean {
174
+ return node.type !== undefined;
175
+ }
176
+
177
+ interface MethodInfo {
178
+ name: string;
179
+ line: number;
180
+ endLine: number;
181
+ hasReturnType: boolean;
182
+ hasDisableComment: boolean;
183
+ }
184
+
185
+ /**
186
+ * Parse a TypeScript file and find methods with their return type status.
187
+ */
188
+ // webpieces-disable max-lines-new-methods -- AST traversal requires inline visitor function
189
+ function findMethodsInFile(filePath: string, workspaceRoot: string): MethodInfo[] {
190
+ const fullPath = path.join(workspaceRoot, filePath);
191
+ if (!fs.existsSync(fullPath)) return [];
192
+
193
+ const content = fs.readFileSync(fullPath, 'utf-8');
194
+ const fileLines = content.split('\n');
195
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
196
+
197
+ const methods: MethodInfo[] = [];
198
+
199
+ // webpieces-disable max-lines-new-methods -- AST visitor pattern requires handling multiple node types
200
+ function visit(node: ts.Node): void {
201
+ let methodName: string | undefined;
202
+ let startLine: number | undefined;
203
+ let endLine: number | undefined;
204
+ let hasReturnType = false;
205
+
206
+ if (ts.isMethodDeclaration(node) && node.name) {
207
+ methodName = node.name.getText(sourceFile);
208
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart());
209
+ const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
210
+ startLine = start.line + 1;
211
+ endLine = end.line + 1;
212
+ hasReturnType = hasExplicitReturnType(node);
213
+ } else if (ts.isFunctionDeclaration(node) && node.name) {
214
+ methodName = node.name.getText(sourceFile);
215
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart());
216
+ const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
217
+ startLine = start.line + 1;
218
+ endLine = end.line + 1;
219
+ hasReturnType = hasExplicitReturnType(node);
220
+ } else if (ts.isArrowFunction(node)) {
221
+ if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
222
+ methodName = node.parent.name.getText(sourceFile);
223
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart());
224
+ const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
225
+ startLine = start.line + 1;
226
+ endLine = end.line + 1;
227
+ hasReturnType = hasExplicitReturnType(node);
228
+ }
229
+ }
230
+
231
+ if (methodName && startLine !== undefined && endLine !== undefined) {
232
+ methods.push({
233
+ name: methodName,
234
+ line: startLine,
235
+ endLine,
236
+ hasReturnType,
237
+ hasDisableComment: hasDisableComment(fileLines, startLine),
238
+ });
239
+ }
240
+
241
+ ts.forEachChild(node, visit);
242
+ }
243
+
244
+ visit(sourceFile);
245
+ return methods;
246
+ }
247
+
248
+ /**
249
+ * Parse diff to extract changed line numbers (both additions and modifications).
250
+ */
251
+ function getChangedLineNumbers(diffContent: string): Set<number> {
252
+ const changedLines = new Set<number>();
253
+ const lines = diffContent.split('\n');
254
+ let currentLine = 0;
255
+
256
+ for (const line of lines) {
257
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
258
+ if (hunkMatch) {
259
+ currentLine = parseInt(hunkMatch[1], 10);
260
+ continue;
261
+ }
262
+
263
+ if (line.startsWith('+') && !line.startsWith('+++')) {
264
+ changedLines.add(currentLine);
265
+ currentLine++;
266
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
267
+ // Deletions don't increment line number
268
+ } else {
269
+ currentLine++;
270
+ }
271
+ }
272
+
273
+ return changedLines;
274
+ }
275
+
276
+ /**
277
+ * Check if a method has any changed lines within its range.
278
+ */
279
+ function methodHasChanges(method: MethodInfo, changedLines: Set<number>): boolean {
280
+ for (let line = method.line; line <= method.endLine; line++) {
281
+ if (changedLines.has(line)) {
282
+ return true;
283
+ }
284
+ }
285
+ return false;
286
+ }
287
+
288
+ /**
289
+ * Find NEW methods without explicit return types (NEW_METHODS mode).
290
+ */
291
+ // webpieces-disable max-lines-new-methods -- File iteration with diff parsing and method matching
292
+ function findViolationsForNewMethods(
293
+ workspaceRoot: string,
294
+ changedFiles: string[],
295
+ base: string,
296
+ head: string | undefined,
297
+ disableAllowed: boolean
298
+ ): MethodViolation[] {
299
+ const violations: MethodViolation[] = [];
300
+
301
+ for (const file of changedFiles) {
302
+ const diff = getFileDiff(workspaceRoot, file, base, head);
303
+ const newMethodNames = findNewMethodSignaturesInDiff(diff);
304
+
305
+ if (newMethodNames.size === 0) continue;
306
+
307
+ const methods = findMethodsInFile(file, workspaceRoot);
308
+
309
+ for (const method of methods) {
310
+ if (!newMethodNames.has(method.name)) continue;
311
+ if (method.hasReturnType) continue;
312
+ if (disableAllowed && method.hasDisableComment) continue;
313
+
314
+ violations.push({
315
+ file,
316
+ methodName: method.name,
317
+ line: method.line,
318
+ });
319
+ }
320
+ }
321
+
322
+ return violations;
323
+ }
324
+
325
+ /**
326
+ * Find NEW methods AND methods with changes (NEW_AND_MODIFIED_METHODS mode).
327
+ */
328
+ // webpieces-disable max-lines-new-methods -- Combines new method detection with change detection
329
+ function findViolationsForModifiedAndNewMethods(
330
+ workspaceRoot: string,
331
+ changedFiles: string[],
332
+ base: string,
333
+ head: string | undefined,
334
+ disableAllowed: boolean
335
+ ): MethodViolation[] {
336
+ const violations: MethodViolation[] = [];
337
+
338
+ for (const file of changedFiles) {
339
+ const diff = getFileDiff(workspaceRoot, file, base, head);
340
+ const newMethodNames = findNewMethodSignaturesInDiff(diff);
341
+ const changedLines = getChangedLineNumbers(diff);
342
+
343
+ const methods = findMethodsInFile(file, workspaceRoot);
344
+
345
+ for (const method of methods) {
346
+ if (method.hasReturnType) continue;
347
+ if (disableAllowed && method.hasDisableComment) continue;
348
+
349
+ const isNewMethod = newMethodNames.has(method.name);
350
+ const isModifiedMethod = methodHasChanges(method, changedLines);
351
+
352
+ if (!isNewMethod && !isModifiedMethod) continue;
353
+
354
+ violations.push({
355
+ file,
356
+ methodName: method.name,
357
+ line: method.line,
358
+ });
359
+ }
360
+ }
361
+
362
+ return violations;
363
+ }
364
+
365
+ /**
366
+ * Find all methods without explicit return types in modified files (MODIFIED_FILES mode).
367
+ */
368
+ function findViolationsForModifiedFiles(workspaceRoot: string, changedFiles: string[], disableAllowed: boolean): MethodViolation[] {
369
+ const violations: MethodViolation[] = [];
370
+
371
+ for (const file of changedFiles) {
372
+ const methods = findMethodsInFile(file, workspaceRoot);
373
+
374
+ for (const method of methods) {
375
+ if (method.hasReturnType) continue;
376
+ if (disableAllowed && method.hasDisableComment) continue;
377
+
378
+ violations.push({
379
+ file,
380
+ methodName: method.name,
381
+ line: method.line,
382
+ });
383
+ }
384
+ }
385
+
386
+ return violations;
387
+ }
388
+
389
+ /**
390
+ * Auto-detect the base branch by finding the merge-base with origin/main.
391
+ */
392
+ function detectBase(workspaceRoot: string): string | null {
393
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
394
+ try {
395
+ const mergeBase = execSync('git merge-base HEAD origin/main', {
396
+ cwd: workspaceRoot,
397
+ encoding: 'utf-8',
398
+ stdio: ['pipe', 'pipe', 'pipe'],
399
+ }).trim();
400
+
401
+ if (mergeBase) {
402
+ return mergeBase;
403
+ }
404
+ } catch (err: unknown) {
405
+ //const error = toError(err);
406
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
407
+ try {
408
+ const mergeBase = execSync('git merge-base HEAD main', {
409
+ cwd: workspaceRoot,
410
+ encoding: 'utf-8',
411
+ stdio: ['pipe', 'pipe', 'pipe'],
412
+ }).trim();
413
+
414
+ if (mergeBase) {
415
+ return mergeBase;
416
+ }
417
+ } catch (err2: unknown) {
418
+ //const error2 = toError(err2);
419
+ // Ignore
420
+ }
421
+ }
422
+ return null;
423
+ }
424
+
425
+ /**
426
+ * Report violations to console.
427
+ */
428
+ function reportViolations(violations: MethodViolation[], mode: ReturnTypeMode): void {
429
+ console.error('');
430
+ console.error('❌ Methods missing explicit return types!');
431
+ console.error('');
432
+ console.error('📚 Explicit return types improve code readability:');
433
+ console.error('');
434
+ console.error(' BAD: method() { return new MyClass(); }');
435
+ console.error(' GOOD: method(): MyClass { return new MyClass(); }');
436
+ console.error(' GOOD: async method(): Promise<MyType> { ... }');
437
+ console.error('');
438
+
439
+ for (const v of violations) {
440
+ console.error(` ❌ ${v.file}:${v.line}`);
441
+ console.error(` Method: ${v.methodName} - missing return type annotation`);
442
+ }
443
+ console.error('');
444
+
445
+ console.error(' To fix: Add explicit return type after the parameter list');
446
+ console.error('');
447
+ console.error(' Escape hatch (use sparingly):');
448
+ console.error(' // webpieces-disable require-return-type -- [your reason]');
449
+ console.error('');
450
+ console.error(` Current mode: ${mode}`);
451
+ console.error('');
452
+ }
453
+
454
+ /**
455
+ * Resolve mode considering ignoreModifiedUntilEpoch override.
456
+ * When active, downgrades to OFF. When expired, logs a warning.
457
+ */
458
+ function resolveMode(normalMode: ReturnTypeMode, epoch: number | undefined): ReturnTypeMode {
459
+ if (epoch === undefined || normalMode === 'OFF') {
460
+ return normalMode;
461
+ }
462
+ const nowSeconds = Date.now() / 1000;
463
+ if (nowSeconds < epoch) {
464
+ const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];
465
+ console.log(`\n⏭️ Skipping require-return-type validation (ignoreModifiedUntilEpoch active, expires: ${expiresDate})`);
466
+ console.log('');
467
+ return 'OFF';
468
+ }
469
+ return normalMode;
470
+ }
471
+
472
+ export default async function runValidator(
473
+ options: ValidateReturnTypesOptions,
474
+ workspaceRoot: string
475
+ ): Promise<ExecutorResult> {
476
+ const mode: ReturnTypeMode = resolveMode(options.mode ?? 'NEW_METHODS', options.ignoreModifiedUntilEpoch);
477
+ const disableAllowed = options.disableAllowed ?? true;
478
+
479
+ if (mode === 'OFF') {
480
+ console.log('\n⏭️ Skipping return type validation (mode: OFF)');
481
+ console.log('');
482
+ return { success: true };
483
+ }
484
+
485
+ console.log('\n📏 Validating Return Types\n');
486
+ console.log(` Mode: ${mode}`);
487
+
488
+ let base = process.env['NX_BASE'];
489
+ const head = process.env['NX_HEAD'];
490
+
491
+ if (!base) {
492
+ base = detectBase(workspaceRoot) ?? undefined;
493
+
494
+ if (!base) {
495
+ console.log('\n⏭️ Skipping return type validation (could not detect base branch)');
496
+ console.log('');
497
+ return { success: true };
498
+ }
499
+ }
500
+
501
+ console.log(` Base: ${base}`);
502
+ console.log(` Head: ${head ?? 'working tree (includes uncommitted changes)'}`);
503
+ console.log('');
504
+
505
+ const changedFiles = getChangedTypeScriptFiles(workspaceRoot, base, head);
506
+
507
+ if (changedFiles.length === 0) {
508
+ console.log('✅ No TypeScript files changed');
509
+ return { success: true };
510
+ }
511
+
512
+ console.log(`📂 Checking ${changedFiles.length} changed file(s)...`);
513
+
514
+ let violations: MethodViolation[] = [];
515
+
516
+ if (mode === 'NEW_METHODS') {
517
+ violations = findViolationsForNewMethods(workspaceRoot, changedFiles, base, head, disableAllowed);
518
+ } else if (mode === 'NEW_AND_MODIFIED_METHODS') {
519
+ violations = findViolationsForModifiedAndNewMethods(workspaceRoot, changedFiles, base, head, disableAllowed);
520
+ } else if (mode === 'MODIFIED_FILES') {
521
+ violations = findViolationsForModifiedFiles(workspaceRoot, changedFiles, disableAllowed);
522
+ }
523
+
524
+ if (violations.length === 0) {
525
+ console.log('✅ All methods have explicit return types');
526
+ return { success: true };
527
+ }
528
+
529
+ reportViolations(violations, mode);
530
+
531
+ return { success: false };
532
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "forceConsistentCasingInFileNames": true,
6
+ "strict": true,
7
+ "noImplicitOverride": true,
8
+ "noPropertyAccessFromIndexSignature": true,
9
+ "noImplicitReturns": true,
10
+ "noFallthroughCasesInSwitch": true
11
+ },
12
+ "files": [],
13
+ "include": [],
14
+ "references": [
15
+ {
16
+ "path": "./tsconfig.lib.json"
17
+ },
18
+ {
19
+ "path": "./tsconfig.spec.json"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/__tests__/**/*.ts"]
10
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "types": ["jest", "node"]
7
+ },
8
+ "include": [
9
+ "jest.config.ts",
10
+ "src/**/*.test.ts",
11
+ "src/**/*.spec.ts",
12
+ "src/__tests__/**/*.ts"
13
+ ]
14
+ }