@sun-asterisk/sunlint 1.3.23 → 1.3.25

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,373 @@
1
+ /**
2
+ * C021 ts-morph Analyzer - Import Organization
3
+ *
4
+ * Enforces organized imports with proper grouping and sorting:
5
+ * 1. Built-in modules (fs, path, etc.)
6
+ * 2. External dependencies (express, axios, etc.)
7
+ * 3. Internal modules (./utils, ../services, etc.)
8
+ *
9
+ * Within each group, imports should be sorted alphabetically.
10
+ * Blank lines should separate groups.
11
+ *
12
+ * Following Rule C005: Single responsibility - import organization only
13
+ * Following Rule C006: Verb-noun naming
14
+ */
15
+
16
+ const { Project, SyntaxKind, Node } = require('ts-morph');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ class C021TsMorphAnalyzer {
21
+ constructor(semanticEngine = null, options = {}) {
22
+ this.ruleId = 'C021';
23
+ this.ruleName = 'Import Organization';
24
+ this.description = 'Enforce organized imports with grouping and sorting';
25
+ this.semanticEngine = semanticEngine;
26
+ this.project = null;
27
+ this.verbose = false;
28
+
29
+ // Load config
30
+ this.config = this.loadConfig();
31
+ }
32
+
33
+ loadConfig() {
34
+ try {
35
+ const configPath = path.join(__dirname, 'config.json');
36
+ const configData = fs.readFileSync(configPath, 'utf8');
37
+ return JSON.parse(configData).config;
38
+ } catch (error) {
39
+ console.warn('[C021] Could not load config, using defaults');
40
+ return {
41
+ groups: [
42
+ {
43
+ name: 'builtin',
44
+ patterns: ['^(fs|path|http|https|crypto)$']
45
+ },
46
+ {
47
+ name: 'external',
48
+ patterns: ['^[^.]']
49
+ },
50
+ {
51
+ name: 'internal',
52
+ patterns: ['^\\\\.|^@/|^~/']
53
+ }
54
+ ],
55
+ sortOrder: {
56
+ groupOrder: ['builtin', 'external', 'internal'],
57
+ withinGroup: 'alphabetical'
58
+ },
59
+ spacing: {
60
+ requireBlankLineBetweenGroups: true
61
+ }
62
+ };
63
+ }
64
+ }
65
+
66
+ async initialize(semanticEngine = null) {
67
+ if (semanticEngine) {
68
+ this.semanticEngine = semanticEngine;
69
+ }
70
+ this.verbose = semanticEngine?.verbose || false;
71
+
72
+ // Use semantic engine's project if available
73
+ if (this.semanticEngine?.project) {
74
+ this.project = this.semanticEngine.project;
75
+ if (this.verbose) {
76
+ console.log('[DEBUG] 🎯 C021: Using semantic engine project');
77
+ }
78
+ } else {
79
+ this.project = new Project({
80
+ compilerOptions: {
81
+ target: 99,
82
+ module: 99,
83
+ allowJs: true,
84
+ checkJs: false,
85
+ jsx: 2,
86
+ },
87
+ });
88
+ if (this.verbose) {
89
+ console.log('[DEBUG] 🎯 C021: Created standalone ts-morph project');
90
+ }
91
+ }
92
+ }
93
+
94
+ async analyze(files, language, options = {}) {
95
+ this.verbose = options.verbose || this.verbose;
96
+
97
+ if (!this.project) {
98
+ await this.initialize();
99
+ }
100
+
101
+ const violations = [];
102
+
103
+ for (const filePath of files) {
104
+ try {
105
+ const fileViolations = await this.analyzeFile(filePath, options);
106
+ violations.push(...fileViolations);
107
+ } catch (error) {
108
+ if (this.verbose) {
109
+ console.warn(`[C021] Error analyzing ${filePath}:`, error.message);
110
+ }
111
+ }
112
+ }
113
+
114
+ return violations;
115
+ }
116
+
117
+ async analyzeFile(filePath, options = {}) {
118
+ // Get or add source file
119
+ let sourceFile = this.project.getSourceFile(filePath);
120
+
121
+ if (!sourceFile && fs.existsSync(filePath)) {
122
+ sourceFile = this.project.addSourceFileAtPath(filePath);
123
+ }
124
+
125
+ if (!sourceFile) {
126
+ return [];
127
+ }
128
+
129
+ const violations = [];
130
+
131
+ // Get all import declarations
132
+ const imports = sourceFile.getImportDeclarations();
133
+
134
+ if (imports.length === 0) {
135
+ return [];
136
+ }
137
+
138
+ // Organize imports into groups
139
+ const importGroups = this.organizeImports(imports);
140
+
141
+ // Check group order
142
+ const orderViolations = this.checkGroupOrder(importGroups, imports);
143
+ violations.push(...orderViolations);
144
+
145
+ // Check sorting within groups
146
+ const sortViolations = this.checkGroupSorting(importGroups);
147
+ violations.push(...sortViolations);
148
+
149
+ // Check spacing between groups
150
+ const spacingViolations = this.checkGroupSpacing(importGroups, sourceFile);
151
+ violations.push(...spacingViolations);
152
+
153
+ return violations.map(v => ({
154
+ ...v,
155
+ filePath: sourceFile.getFilePath()
156
+ }));
157
+ }
158
+
159
+ /**
160
+ * Organize imports into groups based on config
161
+ */
162
+ organizeImports(imports) {
163
+ const groups = {};
164
+
165
+ // Initialize groups
166
+ for (const groupConfig of this.config.groups) {
167
+ groups[groupConfig.name] = [];
168
+ }
169
+
170
+ // Categorize each import
171
+ for (const importDecl of imports) {
172
+ const moduleSpecifier = importDecl.getModuleSpecifierValue();
173
+ const groupName = this.categorizeImport(moduleSpecifier);
174
+
175
+ if (groupName && groups[groupName]) {
176
+ groups[groupName].push({
177
+ node: importDecl,
178
+ moduleSpecifier,
179
+ line: importDecl.getStartLineNumber()
180
+ });
181
+ }
182
+ }
183
+
184
+ return groups;
185
+ }
186
+
187
+ /**
188
+ * Categorize an import into a group
189
+ */
190
+ categorizeImport(moduleSpecifier) {
191
+ for (const groupConfig of this.config.groups) {
192
+ for (const pattern of groupConfig.patterns) {
193
+ const regex = new RegExp(pattern);
194
+ if (regex.test(moduleSpecifier)) {
195
+ return groupConfig.name;
196
+ }
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * Check if groups appear in correct order
204
+ */
205
+ checkGroupOrder(importGroups, allImports) {
206
+ const violations = [];
207
+ const expectedOrder = this.config.sortOrder.groupOrder;
208
+
209
+ // Get actual order of groups
210
+ const actualOrder = [];
211
+ const groupFirstLines = new Map();
212
+
213
+ for (const importInfo of allImports.map(imp => ({
214
+ node: imp,
215
+ moduleSpecifier: imp.getModuleSpecifierValue(),
216
+ line: imp.getStartLineNumber()
217
+ }))) {
218
+ const groupName = this.categorizeImport(importInfo.moduleSpecifier);
219
+ if (groupName && !groupFirstLines.has(groupName)) {
220
+ groupFirstLines.set(groupName, importInfo.line);
221
+ actualOrder.push(groupName);
222
+ }
223
+ }
224
+
225
+ // Check if actual order matches expected order
226
+ const expectedFiltered = expectedOrder.filter(g => actualOrder.includes(g));
227
+
228
+ for (let i = 0; i < actualOrder.length; i++) {
229
+ if (actualOrder[i] !== expectedFiltered[i]) {
230
+ // Find the misplaced import
231
+ const misplacedGroup = actualOrder[i];
232
+ const imports = importGroups[misplacedGroup];
233
+
234
+ if (imports && imports.length > 0) {
235
+ violations.push({
236
+ ruleId: this.ruleId,
237
+ message: `Import group "${misplacedGroup}" should come ${this.getOrderMessage(misplacedGroup, expectedFiltered, i)}`,
238
+ severity: 'info',
239
+ location: {
240
+ start: {
241
+ line: imports[0].line,
242
+ column: 1
243
+ },
244
+ end: {
245
+ line: imports[0].line,
246
+ column: 1
247
+ }
248
+ },
249
+ context: {
250
+ violationType: 'group-order',
251
+ currentGroup: misplacedGroup,
252
+ expectedOrder: expectedFiltered
253
+ }
254
+ });
255
+ }
256
+ break;
257
+ }
258
+ }
259
+
260
+ return violations;
261
+ }
262
+
263
+ /**
264
+ * Get descriptive message for group order violation
265
+ */
266
+ getOrderMessage(groupName, expectedOrder, currentIndex) {
267
+ const expectedIndex = expectedOrder.indexOf(groupName);
268
+ if (expectedIndex < currentIndex) {
269
+ return `before "${expectedOrder[currentIndex]}" imports`;
270
+ } else {
271
+ return `after "${expectedOrder[currentIndex - 1]}" imports`;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Check sorting within each group
277
+ */
278
+ checkGroupSorting(importGroups) {
279
+ const violations = [];
280
+
281
+ for (const [groupName, imports] of Object.entries(importGroups)) {
282
+ if (imports.length <= 1) continue;
283
+
284
+ // Check if sorted alphabetically
285
+ for (let i = 0; i < imports.length - 1; i++) {
286
+ const current = imports[i].moduleSpecifier;
287
+ const next = imports[i + 1].moduleSpecifier;
288
+
289
+ if (current.toLowerCase() > next.toLowerCase()) {
290
+ violations.push({
291
+ ruleId: this.ruleId,
292
+ message: `Import "${current}" should come after "${next}" (alphabetical order within ${groupName} group)`,
293
+ severity: 'info',
294
+ location: {
295
+ start: {
296
+ line: imports[i].line,
297
+ column: 1
298
+ },
299
+ end: {
300
+ line: imports[i].line,
301
+ column: 1
302
+ }
303
+ },
304
+ context: {
305
+ violationType: 'sorting',
306
+ group: groupName,
307
+ currentImport: current,
308
+ nextImport: next
309
+ }
310
+ });
311
+ }
312
+ }
313
+ }
314
+
315
+ return violations;
316
+ }
317
+
318
+ /**
319
+ * Check spacing between groups
320
+ */
321
+ checkGroupSpacing(importGroups, sourceFile) {
322
+ const violations = [];
323
+
324
+ if (!this.config.spacing.requireBlankLineBetweenGroups) {
325
+ return violations;
326
+ }
327
+
328
+ const expectedOrder = this.config.sortOrder.groupOrder;
329
+ const nonEmptyGroups = expectedOrder.filter(groupName =>
330
+ importGroups[groupName] && importGroups[groupName].length > 0
331
+ );
332
+
333
+ // Check spacing between consecutive groups
334
+ for (let i = 0; i < nonEmptyGroups.length - 1; i++) {
335
+ const currentGroup = importGroups[nonEmptyGroups[i]];
336
+ const nextGroup = importGroups[nonEmptyGroups[i + 1]];
337
+
338
+ const lastImportOfCurrentGroup = currentGroup[currentGroup.length - 1];
339
+ const firstImportOfNextGroup = nextGroup[0];
340
+
341
+ const lastLine = lastImportOfCurrentGroup.line;
342
+ const nextLine = firstImportOfNextGroup.line;
343
+
344
+ // Should have at least 1 blank line between groups
345
+ if (nextLine - lastLine < 2) {
346
+ violations.push({
347
+ ruleId: this.ruleId,
348
+ message: `Missing blank line between "${nonEmptyGroups[i]}" and "${nonEmptyGroups[i + 1]}" import groups`,
349
+ severity: 'info',
350
+ location: {
351
+ start: {
352
+ line: lastLine,
353
+ column: 1
354
+ },
355
+ end: {
356
+ line: nextLine,
357
+ column: 1
358
+ }
359
+ },
360
+ context: {
361
+ violationType: 'spacing',
362
+ currentGroup: nonEmptyGroups[i],
363
+ nextGroup: nonEmptyGroups[i + 1]
364
+ }
365
+ });
366
+ }
367
+ }
368
+
369
+ return violations;
370
+ }
371
+ }
372
+
373
+ module.exports = C021TsMorphAnalyzer;
@@ -49,7 +49,7 @@ class C029Analyzer {
49
49
  ],
50
50
 
51
51
  // Test file patterns (more lenient checking)
52
- testPatterns: ['__tests__', '.test.', '.spec.', '/test/', '/tests/', '.stories.']
52
+ testPatterns: ['__tests__', '.test.', '.spec.', '/test/', '/tests/', '.stories.', '-test.', '-spec.', 'test-fixtures']
53
53
  };
54
54
  }
55
55
 
@@ -154,6 +154,35 @@ class C029Analyzer {
154
154
 
155
155
  // STAGE 1: Check if catch block is empty
156
156
  if (this.isEmptyCatchBlock(block)) {
157
+ // Allow empty catch in test files if there's any comment explaining it
158
+ const hasComment = this.hasAnyComment(catchClause);
159
+ if (this.isTestFile(filePath) && hasComment) {
160
+ // Test files with explanatory comments are OK
161
+ return violations;
162
+ }
163
+
164
+ // Allow empty catch if there's a finally block with cleanup
165
+ // Common pattern: try { } catch { } finally { setLoading(false); }
166
+ const tryStatement = catchClause.getParent();
167
+ if (tryStatement && tryStatement.getFinallyBlock()) {
168
+ const finallyBlock = tryStatement.getFinallyBlock();
169
+ const finallyText = finallyBlock.getText();
170
+
171
+ // Check if finally block does cleanup (setState, setLoading, cleanup, etc.)
172
+ const cleanupPatterns = [
173
+ /set[A-Z]\w*\s*\(/, // setLoading, setState, setError, etc.
174
+ /cleanup/i, // cleanup function
175
+ /reset/i, // reset function
176
+ /\.close\s*\(/, // close connections
177
+ /\.disconnect\s*\(/, // disconnect
178
+ /finally/i // any finally-related code
179
+ ];
180
+
181
+ if (cleanupPatterns.some(pattern => pattern.test(finallyText))) {
182
+ return violations; // Acceptable: empty catch with finally cleanup
183
+ }
184
+ }
185
+
157
186
  violations.push(this.createViolation(
158
187
  filePath,
159
188
  startLine,
@@ -338,7 +367,7 @@ class C029Analyzer {
338
367
  const issues = [];
339
368
 
340
369
  // Issue 1: Using inappropriate log level (log/info/debug instead of error/warn)
341
- const hasInappropriateLevel = loggingInfo.logLevels.some(level =>
370
+ const hasInappropriateLevel = loggingInfo.logLevels.some(level =>
342
371
  this.config.inappropriateLevels.includes(level)
343
372
  );
344
373
 
@@ -361,26 +390,9 @@ class C029Analyzer {
361
390
  });
362
391
  }
363
392
 
364
- // Issue 3: No stack trace (optional but recommended)
365
- if (exceptionVar && loggingInfo.usesExceptionVar && !loggingInfo.hasStackTrace) {
366
- // This is a warning, not critical
367
- issues.push({
368
- type: 'missing_stack_trace',
369
- message: `Logging does not include stack trace (${exceptionVar}.stack)`,
370
- suggestion: `Consider logging ${exceptionVar}.stack for better debugging`,
371
- confidence: 0.60
372
- });
373
- }
374
-
375
- // Issue 4: No context data (optional but recommended)
376
- if (!loggingInfo.hasContextData && !this.isTestFile(filePath)) {
377
- issues.push({
378
- type: 'missing_context_data',
379
- message: 'Error logging lacks context information (user ID, request ID, parameters)',
380
- suggestion: 'Add context object with relevant data: { userId, requestId, ...params, error }',
381
- confidence: 0.55
382
- });
383
- }
393
+ // REMOVED: Issue 3 & 4 (missing stack trace and context data)
394
+ // These are optional best practices, not violations. They created too much noise.
395
+ // Teams can enable them separately if desired through configuration.
384
396
 
385
397
  return issues;
386
398
  }
@@ -414,7 +426,20 @@ class C029Analyzer {
414
426
  }
415
427
  }
416
428
 
417
- // Pattern 3: Common error handler functions
429
+ // Pattern 2b: Return default value (defensive programming)
430
+ // Common in frontend: catch { return []; } or catch { return null; }
431
+ // This is acceptable - returning safe default instead of crashing
432
+ if (/\breturn\b/.test(text)) {
433
+ const returnStatements = block.getDescendantsOfKind(SyntaxKind.ReturnStatement);
434
+ const statements = block.getStatements();
435
+
436
+ // If catch block ONLY contains a return statement, it's defensive programming
437
+ if (returnStatements.length === 1 && statements.length === 1) {
438
+ return true; // Acceptable pattern: return default value
439
+ }
440
+ }
441
+
442
+ // Pattern 3: Common error handler functions and external error trackers
418
443
  const errorHandlerPatterns = [
419
444
  /handleError\s*\(/,
420
445
  /processError\s*\(/,
@@ -425,35 +450,72 @@ class C029Analyzer {
425
450
  /externalErrorHandler\s*\(/,
426
451
  /logError\s*\(/,
427
452
  /captureError\s*\(/,
428
- /recordError\s*\(/
453
+ /recordError\s*\(/,
454
+ // External error tracking services
455
+ /Sentry\.captureException\s*\(/,
456
+ /Bugsnag\.notify\s*\(/,
457
+ /Rollbar\.error\s*\(/,
458
+ /Airbrake\.notify\s*\(/,
459
+ /Raygun\.send\s*\(/,
460
+ // Common utility patterns
461
+ /sendToSentry\s*\(/,
462
+ /utils?\.log/i,
463
+ /helpers?\.log/i,
464
+ /ErrorUtils/,
465
+ /ErrorService/
429
466
  ];
430
467
 
431
468
  if (errorHandlerPatterns.some(pattern => pattern.test(text))) {
432
469
  return true;
433
470
  }
434
471
 
435
- // Pattern 4: Delegation - calling ANY function/helper with exception variable
472
+ // Pattern 4: React/Vue state management (error stored in state)
473
+ // Examples: setError(error), dispatch(setError(error)), setState({error})
474
+ if (exceptionVar) {
475
+ const stateManagementPatterns = [
476
+ /set[A-Z]\w*\s*\(/, // setState, setError, setLoading
477
+ /dispatch\s*\(/, // Redux dispatch
478
+ /commit\s*\(/, // Vuex commit
479
+ /this\.setState\s*\(/, // Class component setState
480
+ /useState/, // React hooks
481
+ /useReducer/ // React reducer hook
482
+ ];
483
+
484
+ if (stateManagementPatterns.some(pattern => pattern.test(text))) {
485
+ // Check if error is passed to state function
486
+ const callExpressions = block.getDescendantsOfKind(SyntaxKind.CallExpression);
487
+ for (const call of callExpressions) {
488
+ const args = call.getArguments();
489
+ const argsText = args.map(arg => arg.getText()).join(' ');
490
+ if (new RegExp(`\\b${exceptionVar}\\b`).test(argsText)) {
491
+ return true; // Error stored in state
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ // Pattern 5: Delegation - calling ANY function/helper with exception variable
436
498
  // Examples: handleApiError(error), utils.logException(err), sendToSentry(e)
437
499
  if (exceptionVar) {
438
500
  const callExpressions = block.getDescendantsOfKind(SyntaxKind.CallExpression);
439
-
501
+
440
502
  for (const call of callExpressions) {
441
503
  const args = call.getArguments();
442
-
504
+
443
505
  // Check if exception variable is passed to any function
444
506
  for (const arg of args) {
445
507
  const argText = arg.getText().trim();
446
-
508
+
447
509
  // Direct usage: someFunction(error)
448
510
  if (argText === exceptionVar) {
449
511
  return true;
450
512
  }
451
-
513
+
452
514
  // Property access: someFunction(error.message)
453
515
  if (argText.startsWith(exceptionVar + '.')) {
454
516
  return true;
455
517
  }
456
-
518
+
457
519
  // In object/array: someFunction({error: error})
458
520
  if (new RegExp(`\\b${exceptionVar}\\b`).test(argText)) {
459
521
  return true;
@@ -482,12 +544,25 @@ class C029Analyzer {
482
544
  return (matches.length - declarationMatches.length) > 0;
483
545
  }
484
546
 
547
+ /**
548
+ * Check if catch clause has any comment (for test files)
549
+ */
550
+ hasAnyComment(catchClause) {
551
+ const text = catchClause.getText();
552
+
553
+ // Check for any line or block comments
554
+ const hasLineComment = /\/\/.*\S/.test(text);
555
+ const hasBlockComment = /\/\*[\s\S]*?\*\//.test(text);
556
+
557
+ return hasLineComment || hasBlockComment;
558
+ }
559
+
485
560
  /**
486
561
  * Check if catch clause has explicit ignore comment
487
562
  */
488
563
  hasExplicitIgnoreComment(catchClause) {
489
564
  const text = catchClause.getText();
490
-
565
+
491
566
  const ignorePatterns = [
492
567
  /\/\/\s*ignore/i,
493
568
  /\/\/\s*TODO/i,