@sun-asterisk/sunlint 1.3.21 → 1.3.23

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.
@@ -196,13 +196,24 @@ class AnalysisOrchestrator {
196
196
 
197
197
  // Group rules by their preferred engines
198
198
  const engineGroups = this.groupRulesByEngine(optimizedRules, config);
199
-
199
+
200
+ // Calculate total batches for progress tracking
201
+ let totalBatches = 0;
202
+ const engineBatchInfo = new Map();
203
+ for (const [engineName, rules] of engineGroups) {
204
+ const ruleBatches = this.performanceOptimizer.createRuleBatches(rules, config);
205
+ engineBatchInfo.set(engineName, ruleBatches);
206
+ totalBatches += ruleBatches.length;
207
+ }
208
+
200
209
  if (!options.quiet) {
201
- console.log(chalk.cyan(`🚀 Running analysis across ${engineGroups.size} engines...`));
210
+ console.log(chalk.cyan(`🚀 Running analysis across ${engineGroups.size} engines (${totalBatches} batches total)...`));
202
211
  }
203
212
 
204
213
  // Run analysis on each engine with batching
205
214
  const results = [];
215
+ let completedBatches = 0;
216
+
206
217
  for (const [engineName, rules] of engineGroups) {
207
218
  const engine = this.engines.get(engineName);
208
219
  if (!engine) {
@@ -210,38 +221,44 @@ class AnalysisOrchestrator {
210
221
  continue;
211
222
  }
212
223
 
213
- // Process rules in batches for performance
214
- const ruleBatches = this.performanceOptimizer.createRuleBatches(rules, config);
215
-
224
+ // Get pre-calculated batches
225
+ const ruleBatches = engineBatchInfo.get(engineName);
226
+
216
227
  for (let i = 0; i < ruleBatches.length; i++) {
217
228
  const batch = ruleBatches[i];
218
229
  const batchNumber = i + 1;
219
-
220
- if (!options.quiet && ruleBatches.length > 1) {
221
- console.log(chalk.blue(`⚙️ ${engineName} - Batch ${batchNumber}/${ruleBatches.length}: ${batch.length} rules`));
222
- } else if (!options.quiet) {
223
- console.log(chalk.blue(`⚙️ Running ${batch.length} rules on ${engineName} engine...`));
230
+ const overallProgress = Math.round((completedBatches / totalBatches) * 100);
231
+
232
+ if (!options.quiet) {
233
+ if (ruleBatches.length > 1) {
234
+ console.log(chalk.blue(`⚙️ [${overallProgress}%] ${engineName} - Batch ${batchNumber}/${ruleBatches.length}: ${batch.length} rules (${optimizedFiles.length} files)`));
235
+ } else {
236
+ console.log(chalk.blue(`⚙️ [${overallProgress}%] Running ${batch.length} rules on ${engineName} engine (${optimizedFiles.length} files)...`));
237
+ }
224
238
  }
225
239
 
226
240
  try {
227
241
  const engineResult = await this.runEngineWithOptimizations(
228
- engine,
229
- optimizedFiles,
230
- batch,
242
+ engine,
243
+ optimizedFiles,
244
+ batch,
231
245
  options,
232
- { batchNumber, totalBatches: ruleBatches.length }
246
+ { batchNumber, totalBatches: ruleBatches.length, overallProgress }
233
247
  );
234
-
248
+
235
249
  results.push({
236
250
  engine: engineName,
237
251
  batch: batchNumber,
238
252
  rules: batch.map(r => r.id),
239
253
  ...engineResult
240
254
  });
241
-
255
+
256
+ completedBatches++;
257
+ const newProgress = Math.round((completedBatches / totalBatches) * 100);
258
+
242
259
  if (!options.quiet) {
243
260
  const violationCount = this.countViolations(engineResult);
244
- console.log(chalk.blue(`✅ ${engineName} batch ${batchNumber}: ${violationCount} violations found`));
261
+ console.log(chalk.green(`✅ [${newProgress}%] ${engineName} batch ${batchNumber}/${ruleBatches.length}: ${violationCount} violations found`));
245
262
  }
246
263
  } catch (error) {
247
264
  // Enhanced error recovery with batch context
package/core/git-utils.js CHANGED
@@ -121,6 +121,23 @@ class GitUtils {
121
121
  // Get git root directory
122
122
  const gitRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf8' }).trim();
123
123
 
124
+ // Check if we're in PR context
125
+ const prContext = this.detectPRContext();
126
+
127
+ // If in PR context and no explicit baseRef, try PR-specific logic
128
+ if (prContext && !baseRef) {
129
+ try {
130
+ const prFiles = this.getPRChangedFiles(prContext, gitRoot);
131
+ if (prFiles && prFiles.length >= 0) {
132
+ return prFiles;
133
+ }
134
+ } catch (prError) {
135
+ // Log warning and fallback to standard logic
136
+ console.warn(`⚠️ PR context detected but failed to get changed files: ${prError.message}`);
137
+ console.log(` Falling back to standard git diff logic...`);
138
+ }
139
+ }
140
+
124
141
  // Auto-detect base ref if not provided
125
142
  const actualBaseRef = baseRef || this.getSmartBaseRef(cwd);
126
143
 
@@ -159,6 +176,194 @@ class GitUtils {
159
176
  }
160
177
  }
161
178
 
179
+ /**
180
+ * Get changed files in PR context using merge-base
181
+ * @param {Object} prContext - PR context info from detectPRContext()
182
+ * @param {string} gitRoot - Git repository root path
183
+ * @returns {string[]} Array of changed file paths in the PR
184
+ */
185
+ static getPRChangedFiles(prContext, gitRoot) {
186
+ try {
187
+ const { baseBranch, provider } = prContext;
188
+
189
+ console.log(`🔍 Detecting changed files for PR (provider: ${provider}, base: ${baseBranch})`);
190
+
191
+ // Try to find the base branch reference
192
+ let baseRef = this.findBaseRef(baseBranch, gitRoot);
193
+
194
+ if (!baseRef) {
195
+ console.log(`⚠️ Base ref not found locally, attempting to fetch origin/${baseBranch}...`);
196
+
197
+ // Try to fetch and create the ref
198
+ const fetchSuccess = this.ensureBaseRefExists(`origin/${baseBranch}`, gitRoot);
199
+
200
+ if (fetchSuccess) {
201
+ baseRef = `origin/${baseBranch}`;
202
+ } else {
203
+ throw new Error(`Cannot find or fetch base branch: ${baseBranch}`);
204
+ }
205
+ } else {
206
+ // Ensure we have the latest
207
+ console.log(`✅ Found base ref: ${baseRef}`);
208
+ this.ensureBaseRefExists(baseRef, gitRoot);
209
+ }
210
+
211
+ // Use merge-base to find the common ancestor
212
+ let mergeBase;
213
+ try {
214
+ mergeBase = execSync(`git merge-base ${baseRef} HEAD`, {
215
+ cwd: gitRoot,
216
+ encoding: 'utf8'
217
+ }).trim();
218
+ console.log(`✅ Found merge-base: ${mergeBase.substring(0, 8)}`);
219
+ } catch (error) {
220
+ // If merge-base fails, fall back to direct comparison
221
+ console.warn(`⚠️ Could not find merge-base, using direct diff with ${baseRef}`);
222
+ mergeBase = baseRef;
223
+ }
224
+
225
+ // Get all files changed from merge-base to HEAD
226
+ const command = `git diff --name-only ${mergeBase}...HEAD`;
227
+ const output = execSync(command, { cwd: gitRoot, encoding: 'utf8' });
228
+
229
+ const changedFiles = output
230
+ .split('\n')
231
+ .filter(file => file.trim() !== '')
232
+ .map(file => path.resolve(gitRoot, file))
233
+ .filter(file => fs.existsSync(file));
234
+
235
+ console.log(`✅ Found ${changedFiles.length} changed files in PR`);
236
+
237
+ return changedFiles;
238
+ } catch (error) {
239
+ throw new Error(`Failed to get PR changed files: ${error.message}`);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Find base reference for the given branch name
245
+ * @param {string} baseBranch - Base branch name
246
+ * @param {string} gitRoot - Git repository root path
247
+ * @returns {string|null} Base reference or null if not found
248
+ */
249
+ static findBaseRef(baseBranch, gitRoot) {
250
+ const candidates = [
251
+ `origin/${baseBranch}`,
252
+ `upstream/${baseBranch}`,
253
+ baseBranch
254
+ ];
255
+
256
+ for (const candidate of candidates) {
257
+ try {
258
+ execSync(`git rev-parse --verify ${candidate}`, {
259
+ cwd: gitRoot,
260
+ stdio: 'ignore'
261
+ });
262
+ return candidate;
263
+ } catch (error) {
264
+ // Continue to next candidate
265
+ }
266
+ }
267
+
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Ensure base ref exists (fetch if necessary)
273
+ * @param {string} baseRef - Base reference
274
+ * @param {string} gitRoot - Git repository root path
275
+ */
276
+ static ensureBaseRefExists(baseRef, gitRoot) {
277
+ try {
278
+ // Check if ref exists
279
+ execSync(`git rev-parse --verify ${baseRef}`, {
280
+ cwd: gitRoot,
281
+ stdio: 'ignore'
282
+ });
283
+ // Ref exists, return true
284
+ return true;
285
+ } catch (error) {
286
+ // Try to fetch if it doesn't exist
287
+ const parts = baseRef.split('/');
288
+ const remote = parts[0];
289
+ const branch = parts.slice(1).join('/');
290
+
291
+ if (remote === 'origin' || remote === 'upstream') {
292
+ try {
293
+ console.log(`⬇️ Fetching ${remote}/${branch}...`);
294
+
295
+ // Check if this is a shallow repository (common in GitHub Actions)
296
+ const isShallow = this.isShallowRepository(gitRoot);
297
+
298
+ if (isShallow) {
299
+ console.log(` ℹ️ Detected shallow clone, fetching with additional history...`);
300
+
301
+ // For shallow clones, we need to:
302
+ // 1. Fetch the base branch
303
+ // 2. Get enough history to find merge-base
304
+ try {
305
+ // Unshallow current branch first to get more history
306
+ execSync(`git fetch --deepen=50`, {
307
+ cwd: gitRoot,
308
+ stdio: 'pipe',
309
+ encoding: 'utf8'
310
+ });
311
+
312
+ // Then fetch the base branch with history
313
+ execSync(`git fetch ${remote} ${branch} --depth=50`, {
314
+ cwd: gitRoot,
315
+ stdio: 'pipe',
316
+ encoding: 'utf8'
317
+ });
318
+ } catch (shallowError) {
319
+ // If deepen fails, try direct fetch
320
+ console.log(` ℹ️ Trying direct fetch...`);
321
+ execSync(`git fetch ${remote} ${branch}`, {
322
+ cwd: gitRoot,
323
+ stdio: 'pipe',
324
+ encoding: 'utf8'
325
+ });
326
+ }
327
+ } else {
328
+ // Normal fetch for non-shallow repos
329
+ execSync(`git fetch ${remote} ${branch}`, {
330
+ cwd: gitRoot,
331
+ stdio: 'pipe',
332
+ encoding: 'utf8'
333
+ });
334
+ }
335
+
336
+ // Verify it now exists
337
+ execSync(`git rev-parse --verify ${baseRef}`, {
338
+ cwd: gitRoot,
339
+ stdio: 'ignore'
340
+ });
341
+
342
+ console.log(`✅ Successfully fetched ${baseRef}`);
343
+ return true;
344
+ } catch (fetchError) {
345
+ console.warn(`⚠️ Failed to fetch ${baseRef}: ${fetchError.message}`);
346
+ return false;
347
+ }
348
+ }
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Check if repository is shallow
355
+ * @param {string} gitRoot - Git repository root path
356
+ * @returns {boolean} True if shallow
357
+ */
358
+ static isShallowRepository(gitRoot) {
359
+ try {
360
+ const shallowFile = path.join(gitRoot, '.git', 'shallow');
361
+ return fs.existsSync(shallowFile);
362
+ } catch (error) {
363
+ return false;
364
+ }
365
+ }
366
+
162
367
  /**
163
368
  * Get list of staged files
164
369
  * @param {string} cwd - Working directory
@@ -44,7 +44,8 @@ class OutputService {
44
44
  const report = this.generateReport(results, metadata, { ...options, format: effectiveFormat });
45
45
 
46
46
  // Console output
47
- if (!options.quiet) {
47
+ // Skip console output when using --github-annotate to avoid JSON clutter
48
+ if (!options.quiet && !githubAnnotateConfig.shouldAnnotate) {
48
49
  console.log(report.formatted);
49
50
  }
50
51
 
@@ -90,15 +90,78 @@ jobs:
90
90
 
91
91
  ## Advanced Usage
92
92
 
93
- ### 1. Analyze only changed files
93
+ ### 1. Analyze only changed files (Auto-detect PR)
94
+
95
+ **⭐ Tính năng mới**: Tự động phát hiện PR context, tự động fetch base branch, và sử dụng merge-base để diff chính xác!
94
96
 
95
97
  ```yaml
96
- - name: Run SunLint on Changed Files
97
- run: sunlint --all --changed-files --github-annotate
98
- env:
99
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
98
+ steps:
99
+ - name: Checkout code
100
+ uses: actions/checkout@v4
101
+ with:
102
+ fetch-depth: 0 # Recommended: fetch full history for accurate diff
103
+
104
+ - name: Run SunLint on Changed Files (Auto-detect)
105
+ run: sunlint --all --changed-files --github-annotate
106
+ env:
107
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
108
+ ```
109
+
110
+ **✨ Đơn giản nhất có thể - không cần config gì thêm!**
111
+
112
+ **Cách hoạt động**:
113
+
114
+ - ✅ Tự động detect GitHub Actions PR event (`GITHUB_EVENT_NAME=pull_request`)
115
+ - ✅ Tự động lấy base branch từ `GITHUB_BASE_REF`
116
+ - ✅ **Tự động fetch base branch nếu chưa có** (không cần thêm step!)
117
+ - ✅ Detect shallow clone và fetch thêm history nếu cần
118
+ - ✅ Sử dụng `git merge-base` để tìm common ancestor
119
+ - ✅ So sánh với merge-base thay vì HEAD → Lấy đúng các file thay đổi trong PR
120
+
121
+ **Không cần:**
122
+
123
+ - ❌ Chỉ định `--diff-base`
124
+ - ❌ Thêm step fetch base branch thủ công
125
+ - ❌ Config phức tạp
126
+
127
+ **Fallback**: Nếu không phát hiện được PR context hoặc không fetch được base branch, sẽ fallback về standard git diff logic
128
+
129
+ **❓ Có cần `fetch-depth: 0` không?**
130
+
131
+ **Không bắt buộc!** Nhưng **strongly recommended** vì performance.
132
+
133
+ | Option | Performance | Checkout Time | Total Time | Accuracy |
134
+ |--------|-------------|---------------|------------|----------|
135
+ | **With `fetch-depth: 0`** ⭐ | Fast | +2-3s | **Fastest** | 100% |
136
+ | Without (shallow clone) | Slower | Fast | Slower | ~95% |
137
+
138
+ **Với `fetch-depth: 0` (Recommended):**
139
+
140
+ ```yaml
141
+ - uses: actions/checkout@v4
142
+ with:
143
+ fetch-depth: 0 # One-time full fetch
144
+ ```
145
+
146
+ - ✅ **Fastest total time** - fetch once, use immediately
147
+ - ✅ **100% accurate** - full history for merge-base
148
+ - ✅ **Simpler** - no runtime fetch needed
149
+
150
+ **Không có `fetch-depth: 0` (Vẫn work):**
151
+
152
+ ```yaml
153
+ - uses: actions/checkout@v4 # Shallow clone (depth=1)
100
154
  ```
101
155
 
156
+ - ✅ Fast checkout
157
+ - ⚠️ **Slower total time** - SunLint must fetch at runtime:
158
+ - Detect shallow clone
159
+ - `git fetch --deepen=50`
160
+ - `git fetch origin/main --depth=50`
161
+ - ⚠️ May miss history for very large PRs
162
+
163
+ **Recommendation**: Dùng `fetch-depth: 0` trừ khi bạn có lý do đặc biệt (e.g., monorepo cực lớn)
164
+
102
165
  ### 2. Save report file + annotate
103
166
 
104
167
  ```yaml
@@ -819,7 +819,14 @@ class HeuristicEngine extends AnalysisEngineInterface {
819
819
  // Group files by language for efficient processing
820
820
  const filesByLanguage = this.groupFilesByLanguage(files);
821
821
 
822
+ // Track progress across rules
823
+ const totalRules = rules.length;
824
+ let processedRules = 0;
825
+
822
826
  for (const rule of rules) {
827
+ processedRules++;
828
+ const ruleProgress = Math.floor((processedRules / totalRules) * 100);
829
+
823
830
  // Special case: Load C047 semantic rule on-demand
824
831
  if (rule.id === 'C047' && !this.semanticRules.has('C047')) {
825
832
  if (options.verbose) {
@@ -827,7 +834,7 @@ class HeuristicEngine extends AnalysisEngineInterface {
827
834
  }
828
835
  await this.manuallyLoadC047();
829
836
  }
830
-
837
+
831
838
  // Lazy load rule if not already loaded
832
839
  if (!this.isRuleSupported(rule.id)) {
833
840
  if (options.verbose) {
@@ -835,7 +842,7 @@ class HeuristicEngine extends AnalysisEngineInterface {
835
842
  }
836
843
  await this.lazyLoadRule(rule.id, options);
837
844
  }
838
-
845
+
839
846
  if (!this.isRuleSupported(rule.id)) {
840
847
  if (options.verbose) {
841
848
  console.warn(`⚠️ Rule ${rule.id} not supported by Heuristic engine, skipping...`);
@@ -845,25 +852,29 @@ class HeuristicEngine extends AnalysisEngineInterface {
845
852
 
846
853
  try {
847
854
  let ruleViolations = [];
848
-
855
+
849
856
  // Check if this is a semantic rule first (higher priority)
850
857
  if (this.semanticRules.has(rule.id)) {
851
- if (options.verbose) {
852
- console.log(`🧠 [HeuristicEngine] Running semantic analysis for rule ${rule.id}`);
853
- }
858
+ const progressInfo = options.batchInfo?.overallProgress !== undefined
859
+ ? `[${options.batchInfo.overallProgress}% overall] `
860
+ : '';
861
+ console.log(`🧠 ${progressInfo}Rule ${processedRules}/${totalRules} (${ruleProgress}%): ${rule.id} - Analyzing ${files.length} files...`);
862
+
854
863
  ruleViolations = await this.analyzeSemanticRule(rule, files, options);
855
864
  } else {
856
865
  // Fallback to traditional analysis
857
- if (options.verbose) {
858
- console.log(`🔧 [HeuristicEngine] Running traditional analysis for rule ${rule.id}`);
859
- }
866
+ const progressInfo = options.batchInfo?.overallProgress !== undefined
867
+ ? `[${options.batchInfo.overallProgress}% overall] `
868
+ : '';
869
+ console.log(`🔧 ${progressInfo}Rule ${processedRules}/${totalRules} (${ruleProgress}%): ${rule.id} - Analyzing ${files.length} files...`);
870
+
860
871
  ruleViolations = await this.analyzeRule(rule, filesByLanguage, options);
861
872
  }
862
873
 
863
874
  if (ruleViolations.length > 0) {
864
875
  // Group violations by file
865
876
  const violationsByFile = this.groupViolationsByFile(ruleViolations);
866
-
877
+
867
878
  for (const [filePath, violations] of violationsByFile) {
868
879
  // Find or create file result
869
880
  let fileResult = results.results.find(r => r.file === filePath);
@@ -875,8 +886,14 @@ class HeuristicEngine extends AnalysisEngineInterface {
875
886
  }
876
887
  }
877
888
 
889
+ // Log completion
890
+ const progressInfo = options.batchInfo?.overallProgress !== undefined
891
+ ? `[${options.batchInfo.overallProgress}% overall] `
892
+ : '';
893
+ console.log(`✅ ${progressInfo}${rule.id}: Found ${ruleViolations.length} violations`);
894
+
878
895
  results.metadata.analyzersUsed.push(rule.id);
879
-
896
+
880
897
  } catch (error) {
881
898
  console.error(`❌ Failed to analyze rule ${rule.id}:`, error.message);
882
899
  // Continue with other rules
@@ -911,16 +928,28 @@ class HeuristicEngine extends AnalysisEngineInterface {
911
928
 
912
929
  const allViolations = [];
913
930
 
914
- // Run semantic analysis for each file
931
+ // Run semantic analysis for each file with progress tracking
932
+ const totalFiles = files.length;
933
+ let processedFiles = 0;
934
+ let lastReportedProgress = 0;
935
+
915
936
  for (const filePath of files) {
916
937
  try {
917
- if (options.verbose) {
918
- console.log(`🧠 [SemanticRule] Analyzing ${path.basename(filePath)} with ${rule.id}`);
938
+ processedFiles++;
939
+ const currentProgress = Math.floor((processedFiles / totalFiles) * 100);
940
+
941
+ // Report progress every 10% or when verbose
942
+ if (options.verbose || (currentProgress >= lastReportedProgress + 10 && currentProgress < 100)) {
943
+ const progressInfo = options.batchInfo?.overallProgress !== undefined
944
+ ? `[${options.batchInfo.overallProgress}% overall] `
945
+ : '';
946
+ console.log(`🧠 ${progressInfo}${rule.id}: Processing file ${processedFiles}/${totalFiles} (${currentProgress}%) - ${path.basename(filePath)}`);
947
+ lastReportedProgress = currentProgress;
919
948
  }
920
-
949
+
921
950
  // Call semantic rule's analyzeFile method
922
951
  await ruleInstance.analyzeFile(filePath, options);
923
-
952
+
924
953
  // Get violations from the rule instance
925
954
  const fileViolations = ruleInstance.getViolations();
926
955
  allViolations.push(...fileViolations);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sunlint",
3
- "version": "1.3.21",
3
+ "version": "1.3.23",
4
4
  "description": "☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards",
5
5
  "main": "cli.js",
6
6
  "bin": {