@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,594 @@
1
+ /**
2
+ * Validate New Methods Executor
3
+ *
4
+ * Validates that newly added methods don't exceed a maximum line count.
5
+ * Runs in affected mode when:
6
+ * 1. NX_BASE environment variable is set (via nx affected), OR
7
+ * 2. Auto-detects base by finding merge-base with origin/main
8
+ *
9
+ * This validator encourages writing methods that read like a "table of contents"
10
+ * where each method call describes a larger piece of work.
11
+ *
12
+ * Usage:
13
+ * nx affected --target=validate-new-methods --base=origin/main
14
+ * OR: runs automatically via build's architecture:validate-complete dependency
15
+ *
16
+ * Escape hatch: Add webpieces-disable max-lines-new-methods comment with justification
17
+ */
18
+
19
+ import { execSync } from 'child_process';
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as ts from 'typescript';
23
+
24
+ export type MethodMaxLimitMode = 'OFF' | 'NEW_METHODS' | 'NEW_AND_MODIFIED_METHODS' | 'MODIFIED_FILES';
25
+
26
+ export interface ValidateNewMethodsOptions {
27
+ limit?: number;
28
+ mode?: MethodMaxLimitMode;
29
+ disableAllowed?: boolean;
30
+ }
31
+
32
+ export interface ExecutorResult {
33
+ success: boolean;
34
+ }
35
+
36
+ interface MethodViolation {
37
+ file: string;
38
+ methodName: string;
39
+ line: number;
40
+ lines: number;
41
+ isNew: boolean;
42
+ limit: number;
43
+ }
44
+
45
+ interface MethodInfo {
46
+ name: string;
47
+ line: number;
48
+ lines: number;
49
+ hasDisableComment: boolean;
50
+ }
51
+
52
+ const TMP_DIR = '.webpieces/instruct-ai';
53
+ const TMP_MD_FILE = 'webpieces.methodsize.md';
54
+
55
+ const METHODSIZE_DOC_CONTENT = `# Instructions: New Method Too Long
56
+
57
+ ## Requirement
58
+
59
+ **~99% of the time**, you can stay under the \`limit\` from nx.json
60
+ by extracting logical units into well-named methods.
61
+ Nearly all software can be written with methods under this size.
62
+
63
+ ## The "Table of Contents" Principle
64
+
65
+ Good code reads like a book's table of contents:
66
+ - Chapter titles (method names) tell you WHAT happens
67
+ - Reading chapter titles gives you the full story
68
+ - You can dive into chapters (implementations) for details
69
+
70
+ ## Why Limit New Methods?
71
+
72
+ Methods under the limit are:
73
+ - Easy to review in a single screen
74
+ - Simple to understand without scrolling
75
+ - Quick for AI to analyze and suggest improvements
76
+ - More testable in isolation
77
+ - Self-documenting through well-named extracted methods
78
+
79
+ Extracting logical units into well-named methods makes code more readable for both
80
+ AI and humans.
81
+
82
+ ## How to Refactor
83
+
84
+ Instead of:
85
+ \`\`\`typescript
86
+ async processOrder(order: Order): Promise<Result> {
87
+ // 50 lines of validation, transformation, saving, notifications...
88
+ }
89
+ \`\`\`
90
+
91
+ Write:
92
+ \`\`\`typescript
93
+ async processOrder(order: Order): Promise<Result> {
94
+ const validated = this.validateOrder(order);
95
+ const transformed = this.applyBusinessRules(validated);
96
+ const saved = await this.saveToDatabase(transformed);
97
+ await this.notifyStakeholders(saved);
98
+ return this.buildResult(saved);
99
+ }
100
+ \`\`\`
101
+
102
+ Now the main method is a "table of contents" - each line tells part of the story!
103
+
104
+ ## Patterns for Extraction
105
+
106
+ ### Pattern 1: Extract Loop Bodies
107
+ \`\`\`typescript
108
+ // BEFORE
109
+ for (const item of items) {
110
+ // 20 lines of processing
111
+ }
112
+
113
+ // AFTER
114
+ for (const item of items) {
115
+ this.processItem(item);
116
+ }
117
+ \`\`\`
118
+
119
+ ### Pattern 2: Extract Conditional Blocks
120
+ \`\`\`typescript
121
+ // BEFORE
122
+ if (isAdmin(user)) {
123
+ // 15 lines of admin logic
124
+ }
125
+
126
+ // AFTER
127
+ if (isAdmin(user)) {
128
+ this.handleAdminUser(user);
129
+ }
130
+ \`\`\`
131
+
132
+ ### Pattern 3: Extract Data Transformations
133
+ \`\`\`typescript
134
+ // BEFORE
135
+ const result = {
136
+ // 10+ lines of object construction
137
+ };
138
+
139
+ // AFTER
140
+ const result = this.buildResultObject(data);
141
+ \`\`\`
142
+
143
+ ## If Refactoring Is Not Feasible
144
+
145
+ Sometimes methods genuinely need to be longer (complex algorithms, state machines, etc.).
146
+
147
+ **Escape hatch**: Add a webpieces-disable comment with justification:
148
+
149
+ \`\`\`typescript
150
+ // webpieces-disable max-lines-new-methods -- Complex state machine, splitting reduces clarity
151
+ async complexStateMachine(): Promise<void> {
152
+ // ... longer method with justification
153
+ }
154
+ \`\`\`
155
+
156
+ ## AI Agent Action Steps
157
+
158
+ 1. **READ** the method to understand its logical sections
159
+ 2. **IDENTIFY** logical units that can be extracted
160
+ 3. **EXTRACT** into well-named private methods
161
+ 4. **VERIFY** the main method now reads like a table of contents
162
+ 5. **IF NOT FEASIBLE**: Add webpieces-disable max-lines-new-methods comment with clear justification
163
+
164
+ ## Remember
165
+
166
+ - Every method you write today will be read many times tomorrow
167
+ - The best code explains itself through structure
168
+ - When in doubt, extract and name it
169
+ `;
170
+
171
+ /**
172
+ * Write the instructions documentation to tmp directory
173
+ */
174
+ function writeTmpInstructions(workspaceRoot: string): string {
175
+ const tmpDir = path.join(workspaceRoot, TMP_DIR);
176
+ const mdPath = path.join(tmpDir, TMP_MD_FILE);
177
+
178
+ fs.mkdirSync(tmpDir, { recursive: true });
179
+ fs.writeFileSync(mdPath, METHODSIZE_DOC_CONTENT);
180
+
181
+ return mdPath;
182
+ }
183
+
184
+ /**
185
+ * Get changed TypeScript files between base and head (or working tree if head not specified).
186
+ * Uses `git diff base [head]` to match what `nx affected` does.
187
+ * When head is NOT specified, also includes untracked files (matching nx affected behavior).
188
+ */
189
+ function getChangedTypeScriptFiles(workspaceRoot: string, base: string, head?: string): string[] {
190
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
191
+ try {
192
+ // If head is specified, diff base to head; otherwise diff base to working tree
193
+ const diffTarget = head ? `${base} ${head}` : base;
194
+ const output = execSync(`git diff --name-only ${diffTarget} -- '*.ts' '*.tsx'`, {
195
+ cwd: workspaceRoot,
196
+ encoding: 'utf-8',
197
+ });
198
+ const changedFiles = output
199
+ .trim()
200
+ .split('\n')
201
+ .filter((f) => f && !f.includes('.spec.ts') && !f.includes('.test.ts'));
202
+
203
+ // When comparing to working tree (no head specified), also include untracked files
204
+ // This matches what nx affected does: "All modified files not yet committed or tracked will also be added"
205
+ if (!head) {
206
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
207
+ try {
208
+ const untrackedOutput = execSync(`git ls-files --others --exclude-standard '*.ts' '*.tsx'`, {
209
+ cwd: workspaceRoot,
210
+ encoding: 'utf-8',
211
+ });
212
+ const untrackedFiles = untrackedOutput
213
+ .trim()
214
+ .split('\n')
215
+ .filter((f) => f && !f.includes('.spec.ts') && !f.includes('.test.ts'));
216
+ // Merge and dedupe
217
+ const allFiles = new Set([...changedFiles, ...untrackedFiles]);
218
+ return Array.from(allFiles);
219
+ } catch (err: unknown) {
220
+ //const error = toError(err);
221
+ // If ls-files fails, just return the changed files
222
+ return changedFiles;
223
+ }
224
+ }
225
+
226
+ return changedFiles;
227
+ } catch (err: unknown) {
228
+ //const error = toError(err);
229
+ return [];
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Get the diff content for a specific file between base and head (or working tree if head not specified).
235
+ * Uses `git diff base [head]` to match what `nx affected` does.
236
+ * For untracked files, returns the entire file content as additions.
237
+ */
238
+ function getFileDiff(workspaceRoot: string, file: string, base: string, head?: string): string {
239
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
240
+ try {
241
+ // If head is specified, diff base to head; otherwise diff base to working tree
242
+ const diffTarget = head ? `${base} ${head}` : base;
243
+ const diff = execSync(`git diff ${diffTarget} -- "${file}"`, {
244
+ cwd: workspaceRoot,
245
+ encoding: 'utf-8',
246
+ });
247
+
248
+ // If diff is empty and we're comparing to working tree, check if it's an untracked file
249
+ if (!diff && !head) {
250
+ const fullPath = path.join(workspaceRoot, file);
251
+ if (fs.existsSync(fullPath)) {
252
+ // Check if file is untracked
253
+ const isUntracked = execSync(`git ls-files --others --exclude-standard "${file}"`, {
254
+ cwd: workspaceRoot,
255
+ encoding: 'utf-8',
256
+ }).trim();
257
+
258
+ if (isUntracked) {
259
+ // For untracked files, treat entire content as additions
260
+ const content = fs.readFileSync(fullPath, 'utf-8');
261
+ const lines = content.split('\n');
262
+ // Create a pseudo-diff where all lines are additions
263
+ return lines.map((line) => `+${line}`).join('\n');
264
+ }
265
+ }
266
+ }
267
+
268
+ return diff;
269
+ } catch (err: unknown) {
270
+ //const error = toError(err);
271
+ return '';
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Parse diff to find newly added method signatures
277
+ */
278
+ function findNewMethodSignaturesInDiff(diffContent: string): Set<string> {
279
+ const newMethods = new Set<string>();
280
+ const lines = diffContent.split('\n');
281
+
282
+ // Patterns to match method definitions
283
+ const patterns = [
284
+ // [export] [async] function methodName( - most explicit, check first
285
+ /^\+\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/,
286
+ // [export] const/let methodName = [async] (
287
+ /^\+\s*(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/,
288
+ // [export] const/let methodName = [async] function
289
+ /^\+\s*(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?function/,
290
+ // class method: [public/private/protected] [static] [async] methodName( - but NOT constructor, if, for, while, etc.
291
+ /^\+\s*(?:(?:public|private|protected)\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\(/,
292
+ ];
293
+
294
+ for (const line of lines) {
295
+ if (line.startsWith('+') && !line.startsWith('+++')) {
296
+ for (const pattern of patterns) {
297
+ const match = line.match(pattern);
298
+ if (match) {
299
+ // Extract method name - now always in capture group 1
300
+ const methodName = match[1];
301
+ if (methodName && !['if', 'for', 'while', 'switch', 'catch', 'constructor'].includes(methodName)) {
302
+ newMethods.add(methodName);
303
+ }
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ return newMethods;
311
+ }
312
+
313
+ /**
314
+ * Check if a line contains a webpieces-disable comment that exempts from new method validation.
315
+ * Both max-lines-new-methods AND max-lines-modified are accepted here.
316
+ */
317
+ function hasDisableComment(lines: string[], lineNumber: number): boolean {
318
+ // Check the line before the method (lineNumber is 1-indexed, array is 0-indexed)
319
+ // We need to check a few lines before in case there's JSDoc or decorators
320
+ const startCheck = Math.max(0, lineNumber - 5);
321
+ for (let i = lineNumber - 2; i >= startCheck; i--) {
322
+ const line = lines[i]?.trim() ?? '';
323
+ // Stop if we hit another function/class/etc
324
+ if (line.startsWith('function ') || line.startsWith('class ') || line.endsWith('}')) {
325
+ break;
326
+ }
327
+ if (line.includes('webpieces-disable')) {
328
+ // Either escape hatch exempts from the lowLimit new method check
329
+ if (line.includes('max-lines-new-methods') || line.includes('max-lines-modified')) {
330
+ return true;
331
+ }
332
+ }
333
+ }
334
+ return false;
335
+ }
336
+
337
+ /**
338
+ * Parse a TypeScript file and find methods with their line counts
339
+ */
340
+ // webpieces-disable max-lines-new-methods -- AST traversal requires inline visitor function
341
+ function findMethodsInFile(filePath: string, workspaceRoot: string): MethodInfo[] {
342
+ const fullPath = path.join(workspaceRoot, filePath);
343
+ if (!fs.existsSync(fullPath)) return [];
344
+
345
+ const content = fs.readFileSync(fullPath, 'utf-8');
346
+ const fileLines = content.split('\n');
347
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
348
+
349
+ const methods: MethodInfo[] = [];
350
+
351
+ function visit(node: ts.Node): void {
352
+ let methodName: string | undefined;
353
+ let startLine: number | undefined;
354
+ let endLine: number | undefined;
355
+
356
+ if (ts.isMethodDeclaration(node) && node.name) {
357
+ methodName = node.name.getText(sourceFile);
358
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart());
359
+ const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
360
+ startLine = start.line + 1;
361
+ endLine = end.line + 1;
362
+ } else if (ts.isFunctionDeclaration(node) && node.name) {
363
+ methodName = node.name.getText(sourceFile);
364
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart());
365
+ const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
366
+ startLine = start.line + 1;
367
+ endLine = end.line + 1;
368
+ } else if (ts.isArrowFunction(node)) {
369
+ // Check if it's assigned to a variable
370
+ if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
371
+ methodName = node.parent.name.getText(sourceFile);
372
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart());
373
+ const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
374
+ startLine = start.line + 1;
375
+ endLine = end.line + 1;
376
+ }
377
+ }
378
+
379
+ if (methodName && startLine !== undefined && endLine !== undefined) {
380
+ methods.push({
381
+ name: methodName,
382
+ line: startLine,
383
+ lines: endLine - startLine + 1,
384
+ hasDisableComment: hasDisableComment(fileLines, startLine),
385
+ });
386
+ }
387
+
388
+ ts.forEachChild(node, visit);
389
+ }
390
+
391
+ visit(sourceFile);
392
+ return methods;
393
+ }
394
+
395
+ /**
396
+ * Find new methods that exceed the line limit
397
+ */
398
+ function findViolations(
399
+ workspaceRoot: string,
400
+ changedFiles: string[],
401
+ base: string,
402
+ limit: number,
403
+ disableAllowed: boolean,
404
+ head?: string
405
+ ): MethodViolation[] {
406
+ const violations: MethodViolation[] = [];
407
+
408
+ for (const file of changedFiles) {
409
+ // Get the diff to find which methods are NEW (not just modified)
410
+ const diff = getFileDiff(workspaceRoot, file, base, head);
411
+ const newMethodNames = findNewMethodSignaturesInDiff(diff);
412
+
413
+ if (newMethodNames.size === 0) continue;
414
+
415
+ // Parse the current file to get method line counts
416
+ const methods = findMethodsInFile(file, workspaceRoot);
417
+
418
+ for (const method of methods) {
419
+ if (!newMethodNames.has(method.name)) continue;
420
+
421
+ if (method.lines > limit) {
422
+ if (!disableAllowed) {
423
+ // No escape possible
424
+ violations.push({
425
+ file,
426
+ methodName: method.name,
427
+ line: method.line,
428
+ lines: method.lines,
429
+ isNew: true,
430
+ limit,
431
+ });
432
+ } else if (!method.hasDisableComment) {
433
+ // Escape allowed but not present
434
+ violations.push({
435
+ file,
436
+ methodName: method.name,
437
+ line: method.line,
438
+ lines: method.lines,
439
+ isNew: true,
440
+ limit,
441
+ });
442
+ }
443
+ }
444
+ }
445
+ }
446
+
447
+ return violations;
448
+ }
449
+
450
+ /**
451
+ * Auto-detect the base branch by finding the merge-base with origin/main.
452
+ * This allows the executor to run even when NX_BASE isn't set (e.g., via dependsOn).
453
+ */
454
+ function detectBase(workspaceRoot: string): string | null {
455
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
456
+ try {
457
+ // First, try to get merge-base with origin/main
458
+ const mergeBase = execSync('git merge-base HEAD origin/main', {
459
+ cwd: workspaceRoot,
460
+ encoding: 'utf-8',
461
+ stdio: ['pipe', 'pipe', 'pipe'],
462
+ }).trim();
463
+
464
+ if (mergeBase) {
465
+ return mergeBase;
466
+ }
467
+ } catch (err: unknown) {
468
+ //const error = toError(err);
469
+ // origin/main might not exist, try main
470
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
471
+ try {
472
+ const mergeBase = execSync('git merge-base HEAD main', {
473
+ cwd: workspaceRoot,
474
+ encoding: 'utf-8',
475
+ stdio: ['pipe', 'pipe', 'pipe'],
476
+ }).trim();
477
+
478
+ if (mergeBase) {
479
+ return mergeBase;
480
+ }
481
+ } catch (err2: unknown) {
482
+ //const error2 = toError(err2);
483
+ // Ignore - will return null
484
+ }
485
+ }
486
+ return null;
487
+ }
488
+
489
+ /**
490
+ * Report violations to the user with helpful instructions
491
+ */
492
+ function reportViolations(violations: MethodViolation[], limit: number, disableAllowed: boolean): void {
493
+ console.error('');
494
+ console.error('\u274c New methods exceed ' + limit + ' line limit!');
495
+ console.error('');
496
+ console.error('\ud83d\udcda Methods should read like a "table of contents" - each method call');
497
+ console.error(' describes a larger piece of work.');
498
+ console.error('');
499
+ console.error('\u26a0\ufe0f *** READ .webpieces/instruct-ai/webpieces.methodsize.md for detailed guidance on how to fix this easily *** \u26a0\ufe0f');
500
+ console.error('');
501
+
502
+ if (disableAllowed) {
503
+ console.error('\u26a0\ufe0f VIOLATIONS (can use escape hatch):');
504
+ } else {
505
+ console.error('\ud83d\udeab VIOLATIONS (cannot be bypassed with disable comment):');
506
+ }
507
+ console.error('');
508
+ for (const v of violations) {
509
+ console.error(` \u274c ${v.file}:${v.line}`);
510
+ console.error(` Method: ${v.methodName} (${v.lines} lines, limit: ${limit})`);
511
+ }
512
+ console.error('');
513
+ if (disableAllowed) {
514
+ console.error(' Use escape: // webpieces-disable max-lines-new-methods -- [your reason]');
515
+ } else {
516
+ console.error(' These methods MUST be refactored - no escape hatch available (disableAllowed=false).');
517
+ }
518
+ console.error('');
519
+ }
520
+
521
+ export default async function runValidator(
522
+ options: ValidateNewMethodsOptions,
523
+ workspaceRoot: string
524
+ ): Promise<ExecutorResult> {
525
+ const limit = options.limit ?? 80;
526
+ const mode: MethodMaxLimitMode = options.mode ?? 'NEW_AND_MODIFIED_METHODS';
527
+ const disableAllowed = options.disableAllowed ?? true;
528
+
529
+ // Skip validation entirely if mode is OFF
530
+ if (mode === 'OFF') {
531
+ console.log('\n\u23ed\ufe0f Skipping new method validation (mode: OFF)');
532
+ console.log('');
533
+ return { success: true };
534
+ }
535
+
536
+ // Check if running in affected mode via NX_BASE, or auto-detect
537
+ // If NX_HEAD is set (via nx affected --head=X), use it; otherwise compare to working tree
538
+ let base = process.env['NX_BASE'];
539
+ const head = process.env['NX_HEAD'];
540
+
541
+ if (!base) {
542
+ // Try to auto-detect base from git merge-base
543
+ base = detectBase(workspaceRoot) ?? undefined;
544
+
545
+ if (!base) {
546
+ console.log('\n\u23ed\ufe0f Skipping new method validation (could not detect base branch)');
547
+ console.log(' To run explicitly: nx affected --target=validate-new-methods --base=origin/main');
548
+ console.log('');
549
+ return { success: true };
550
+ }
551
+
552
+ console.log('\n\ud83d\udccf Validating New Method Sizes (auto-detected base)\n');
553
+ } else {
554
+ console.log('\n\ud83d\udccf Validating New Method Sizes\n');
555
+ }
556
+
557
+ console.log(` Base: ${base}`);
558
+ console.log(` Head: ${head ?? 'working tree (includes uncommitted changes)'}`);
559
+ console.log(` Mode: ${mode}`);
560
+ console.log(` Limit for new methods: ${limit} lines (${disableAllowed ? 'can escape' : 'NO escape possible'})`);
561
+ console.log('');
562
+
563
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
564
+ try {
565
+ // Get changed TypeScript files (base to head, or working tree if head not set)
566
+ const changedFiles = getChangedTypeScriptFiles(workspaceRoot, base, head);
567
+
568
+ if (changedFiles.length === 0) {
569
+ console.log('\u2705 No TypeScript files changed');
570
+ return { success: true };
571
+ }
572
+
573
+ console.log(`\ud83d\udcc2 Checking ${changedFiles.length} changed file(s)...`);
574
+
575
+ // Find violations
576
+ const violations = findViolations(workspaceRoot, changedFiles, base, limit, disableAllowed, head);
577
+
578
+ if (violations.length === 0) {
579
+ console.log('\u2705 All new methods are within ' + limit + ' lines');
580
+ return { success: true };
581
+ }
582
+
583
+ // Write instructions file and report violations
584
+ writeTmpInstructions(workspaceRoot);
585
+ reportViolations(violations, limit, disableAllowed);
586
+
587
+ return { success: false };
588
+ } catch (err: unknown) {
589
+ //const error = toError(err);
590
+ const error = err instanceof Error ? err : new Error(String(err));
591
+ console.error('\u274c New method validation failed:', error.message);
592
+ return { success: false };
593
+ }
594
+ }