@sun-asterisk/sunlint 1.3.20 → 1.3.22

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
@@ -12,6 +12,7 @@ const { minimatch } = require('minimatch');
12
12
  class FileTargetingService {
13
13
  constructor() {
14
14
  this.supportedLanguages = ['typescript', 'javascript', 'dart', 'kotlin', 'java', 'swift'];
15
+ this.GitUtils = require('./git-utils');
15
16
  }
16
17
 
17
18
  /**
@@ -22,35 +23,43 @@ class FileTargetingService {
22
23
  try {
23
24
  const startTime = Date.now();
24
25
  const metadata = config._metadata;
25
-
26
+
26
27
  if (cliOptions.verbose) {
27
28
  console.log(chalk.cyan(`📁 File Targeting: ${this.getTargetingMode(metadata)}`));
28
29
  if (metadata?.shouldBypassProjectDiscovery) {
29
30
  console.log(chalk.blue(`🎯 Optimized targeting for ${metadata.analysisScope}`));
30
31
  }
31
32
  }
32
-
33
+
33
34
  let allFiles = [];
34
-
35
- // Smart project-level optimization
36
- const optimizedPaths = this.optimizeProjectPaths(inputPaths, cliOptions);
37
-
38
- // Use enhanced targeting based on metadata
39
- if (metadata?.shouldBypassProjectDiscovery) {
40
- allFiles = await this.collectTargetedFiles(optimizedPaths, config, cliOptions);
35
+
36
+ // Handle --changed-files option (git diff mode)
37
+ if (cliOptions.changedFiles) {
38
+ if (cliOptions.verbose) {
39
+ console.log(chalk.cyan('🔄 Using --changed-files mode (git diff)'));
40
+ }
41
+ allFiles = await this.getGitChangedFiles(cliOptions);
41
42
  } else {
42
- allFiles = await this.collectProjectFiles(optimizedPaths, config, cliOptions);
43
+ // Smart project-level optimization
44
+ const optimizedPaths = this.optimizeProjectPaths(inputPaths, cliOptions);
45
+
46
+ // Use enhanced targeting based on metadata
47
+ if (metadata?.shouldBypassProjectDiscovery) {
48
+ allFiles = await this.collectTargetedFiles(optimizedPaths, config, cliOptions);
49
+ } else {
50
+ allFiles = await this.collectProjectFiles(optimizedPaths, config, cliOptions);
51
+ }
43
52
  }
44
53
 
45
54
  // Apply filtering logic
46
55
  const targetFiles = this.applyFiltering(allFiles, config, cliOptions);
47
-
56
+
48
57
  const duration = Date.now() - startTime;
49
-
58
+
50
59
  if (cliOptions.verbose) {
51
60
  console.log(chalk.green(`✅ File targeting completed in ${duration}ms (${targetFiles.length} files)`));
52
61
  }
53
-
62
+
54
63
  return {
55
64
  files: targetFiles,
56
65
  stats: this.generateStats(targetFiles, config),
@@ -62,6 +71,41 @@ class FileTargetingService {
62
71
  }
63
72
  }
64
73
 
74
+ /**
75
+ * Get files changed in git (for --changed-files option)
76
+ */
77
+ async getGitChangedFiles(cliOptions) {
78
+ try {
79
+ const baseRef = cliOptions.diffBase || null; // null = auto-detect
80
+ const changedFiles = this.GitUtils.getChangedFiles(baseRef);
81
+
82
+ if (cliOptions.verbose) {
83
+ const detectedBase = baseRef || this.GitUtils.getSmartBaseRef();
84
+ console.log(chalk.blue(`ℹ️ Using base ref: ${detectedBase}`));
85
+ console.log(chalk.blue(`ℹ️ Found ${changedFiles.length} changed file(s)`));
86
+
87
+ if (changedFiles.length > 0 && changedFiles.length <= 10) {
88
+ console.log(chalk.gray(' Changed files:'));
89
+ changedFiles.forEach(f => {
90
+ console.log(chalk.gray(` - ${path.relative(process.cwd(), f)}`));
91
+ });
92
+ } else if (changedFiles.length > 10) {
93
+ console.log(chalk.gray(` First 10 changed files:`));
94
+ changedFiles.slice(0, 10).forEach(f => {
95
+ console.log(chalk.gray(` - ${path.relative(process.cwd(), f)}`));
96
+ });
97
+ console.log(chalk.gray(` ... and ${changedFiles.length - 10} more`));
98
+ }
99
+ }
100
+
101
+ return changedFiles;
102
+ } catch (error) {
103
+ console.error(chalk.yellow(`⚠️ Failed to get changed files: ${error.message}`));
104
+ console.error(chalk.yellow('ℹ️ Falling back to all files'));
105
+ return [];
106
+ }
107
+ }
108
+
65
109
  /**
66
110
  * Get targeting mode description
67
111
  */
@@ -293,15 +337,22 @@ class FileTargetingService {
293
337
  if (debug) console.log(`🔍 [DEBUG] applyFiltering: start with ${filteredFiles.length} files`);
294
338
  if (debug) console.log(`🔍 [DEBUG] config.include:`, config.include);
295
339
  if (debug) console.log(`🔍 [DEBUG] cliOptions.include:`, cliOptions.include);
340
+ if (debug) console.log(`🔍 [DEBUG] cliOptions.changedFiles:`, cliOptions.changedFiles);
341
+
342
+ // IMPORTANT: When using --changed-files, skip include patterns
343
+ // Git already filtered the files, we only need to apply excludes
344
+ const skipIncludePatterns = cliOptions.changedFiles;
296
345
 
297
346
  // 1. Apply config include patterns first (medium priority)
298
- if (config.include && config.include.length > 0) {
347
+ if (!skipIncludePatterns && config.include && config.include.length > 0) {
299
348
  filteredFiles = this.applyIncludePatterns(filteredFiles, config.include, debug);
300
349
  if (debug) console.log(`🔍 [DEBUG] After config include: ${filteredFiles.length} files`);
350
+ } else if (skipIncludePatterns && debug) {
351
+ console.log(`🔍 [DEBUG] Skipping config include patterns (--changed-files mode)`);
301
352
  }
302
353
 
303
354
  // 2. Apply CLI include overrides (highest priority - completely overrides config)
304
- if (cliOptions.include) {
355
+ if (!skipIncludePatterns && cliOptions.include) {
305
356
  // CLI include completely replaces config include - start fresh from all files
306
357
  filteredFiles = this.applyIncludePatterns([...files], cliOptions.include, debug);
307
358
  }
package/core/git-utils.js CHANGED
@@ -21,13 +21,98 @@ class GitUtils {
21
21
  }
22
22
  }
23
23
 
24
+ /**
25
+ * Detect if running in PR context (GitHub Actions, GitLab CI, etc.)
26
+ * @returns {Object|null} PR context info or null
27
+ */
28
+ static detectPRContext() {
29
+ // GitHub Actions
30
+ if (process.env.GITHUB_EVENT_NAME === 'pull_request' ||
31
+ process.env.GITHUB_EVENT_NAME === 'pull_request_target') {
32
+ return {
33
+ provider: 'github',
34
+ baseBranch: process.env.GITHUB_BASE_REF,
35
+ headBranch: process.env.GITHUB_HEAD_REF,
36
+ prNumber: process.env.GITHUB_REF ? process.env.GITHUB_REF.match(/refs\/pull\/(\d+)\/merge/)?.[1] : null
37
+ };
38
+ }
39
+
40
+ // GitLab CI
41
+ if (process.env.CI_MERGE_REQUEST_ID) {
42
+ return {
43
+ provider: 'gitlab',
44
+ baseBranch: process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME,
45
+ headBranch: process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME,
46
+ prNumber: process.env.CI_MERGE_REQUEST_IID
47
+ };
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Get smart base reference for diff
55
+ * Auto-detects PR context or falls back to common base branches
56
+ * @param {string} cwd - Working directory
57
+ * @returns {string} Base reference for git diff
58
+ */
59
+ static getSmartBaseRef(cwd = process.cwd()) {
60
+ if (!this.isGitRepository(cwd)) {
61
+ throw new Error('Not a git repository');
62
+ }
63
+
64
+ // Check PR context first
65
+ const prContext = this.detectPRContext();
66
+ if (prContext && prContext.baseBranch) {
67
+ const candidates = [
68
+ `origin/${prContext.baseBranch}`,
69
+ `upstream/${prContext.baseBranch}`,
70
+ prContext.baseBranch
71
+ ];
72
+
73
+ for (const candidate of candidates) {
74
+ try {
75
+ execSync(`git rev-parse --verify ${candidate}`, { cwd, stdio: 'ignore' });
76
+ return candidate;
77
+ } catch (error) {
78
+ // Continue to next candidate
79
+ }
80
+ }
81
+ }
82
+
83
+ // Fallback to common base branches
84
+ const fallbackBranches = [
85
+ 'origin/main',
86
+ 'origin/master',
87
+ 'origin/develop',
88
+ 'upstream/main',
89
+ 'upstream/master',
90
+ 'main',
91
+ 'master',
92
+ 'develop'
93
+ ];
94
+
95
+ for (const branch of fallbackBranches) {
96
+ try {
97
+ execSync(`git rev-parse --verify ${branch}`, { cwd, stdio: 'ignore' });
98
+ return branch;
99
+ } catch (error) {
100
+ // Continue to next candidate
101
+ }
102
+ }
103
+
104
+ // Last resort: use HEAD (uncommitted changes only)
105
+ return 'HEAD';
106
+ }
107
+
24
108
  /**
25
109
  * Get list of changed files compared to base reference
26
- * @param {string} baseRef - Base git reference (e.g., 'origin/main')
110
+ * @param {string|null} baseRef - Base git reference (e.g., 'origin/main'). If null, auto-detect.
27
111
  * @param {string} cwd - Working directory
112
+ * @param {boolean} includeUncommitted - Include uncommitted changes
28
113
  * @returns {string[]} Array of changed file paths
29
114
  */
30
- static getChangedFiles(baseRef = 'HEAD', cwd = process.cwd()) {
115
+ static getChangedFiles(baseRef = null, cwd = process.cwd(), includeUncommitted = true) {
31
116
  if (!this.isGitRepository(cwd)) {
32
117
  throw new Error('Not a git repository');
33
118
  }
@@ -35,17 +120,156 @@ class GitUtils {
35
120
  try {
36
121
  // Get git root directory
37
122
  const gitRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf8' }).trim();
38
-
39
- const command = `git diff --name-only ${baseRef}`;
123
+
124
+ // Check if we're in PR context
125
+ const prContext = this.detectPRContext();
126
+
127
+ // If in PR context and no explicit baseRef, use PR-specific logic
128
+ if (prContext && !baseRef) {
129
+ return this.getPRChangedFiles(prContext, gitRoot);
130
+ }
131
+
132
+ // Auto-detect base ref if not provided
133
+ const actualBaseRef = baseRef || this.getSmartBaseRef(cwd);
134
+
135
+ const allFiles = new Set();
136
+
137
+ // Get committed changes
138
+ if (actualBaseRef !== 'HEAD') {
139
+ // Use two-dot diff for branch comparison (what's new in this branch)
140
+ const command = `git diff --name-only ${actualBaseRef}..HEAD`;
141
+ const output = execSync(command, { cwd: gitRoot, encoding: 'utf8' });
142
+
143
+ output
144
+ .split('\n')
145
+ .filter(file => file.trim() !== '')
146
+ .forEach(file => allFiles.add(path.resolve(gitRoot, file)));
147
+ }
148
+
149
+ // Get uncommitted changes if requested
150
+ if (includeUncommitted) {
151
+ const uncommittedCommand = 'git diff --name-only HEAD';
152
+ try {
153
+ const uncommittedOutput = execSync(uncommittedCommand, { cwd: gitRoot, encoding: 'utf8' });
154
+ uncommittedOutput
155
+ .split('\n')
156
+ .filter(file => file.trim() !== '')
157
+ .forEach(file => allFiles.add(path.resolve(gitRoot, file)));
158
+ } catch (error) {
159
+ // Ignore errors for uncommitted changes (might be empty)
160
+ }
161
+ }
162
+
163
+ // Filter to only existing files
164
+ return Array.from(allFiles).filter(file => fs.existsSync(file));
165
+ } catch (error) {
166
+ throw new Error(`Failed to get changed files: ${error.message}`);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Get changed files in PR context using merge-base
172
+ * @param {Object} prContext - PR context info from detectPRContext()
173
+ * @param {string} gitRoot - Git repository root path
174
+ * @returns {string[]} Array of changed file paths in the PR
175
+ */
176
+ static getPRChangedFiles(prContext, gitRoot) {
177
+ try {
178
+ const { baseBranch } = prContext;
179
+
180
+ // Try to find the base branch reference
181
+ const baseRef = this.findBaseRef(baseBranch, gitRoot);
182
+
183
+ if (!baseRef) {
184
+ throw new Error(`Cannot find base branch: ${baseBranch}`);
185
+ }
186
+
187
+ // Ensure we have the latest base branch
188
+ this.ensureBaseRefExists(baseRef, gitRoot);
189
+
190
+ // Use merge-base to find the common ancestor
191
+ let mergeBase;
192
+ try {
193
+ mergeBase = execSync(`git merge-base ${baseRef} HEAD`, {
194
+ cwd: gitRoot,
195
+ encoding: 'utf8'
196
+ }).trim();
197
+ } catch (error) {
198
+ // If merge-base fails, fall back to direct comparison
199
+ console.warn(`Warning: Could not find merge-base, using direct diff with ${baseRef}`);
200
+ mergeBase = baseRef;
201
+ }
202
+
203
+ // Get all files changed from merge-base to HEAD
204
+ const command = `git diff --name-only ${mergeBase}...HEAD`;
40
205
  const output = execSync(command, { cwd: gitRoot, encoding: 'utf8' });
41
-
42
- return output
206
+
207
+ const changedFiles = output
43
208
  .split('\n')
44
209
  .filter(file => file.trim() !== '')
45
210
  .map(file => path.resolve(gitRoot, file))
46
- .filter(file => fs.existsSync(file)); // Only existing files
211
+ .filter(file => fs.existsSync(file));
212
+
213
+ return changedFiles;
47
214
  } catch (error) {
48
- throw new Error(`Failed to get changed files: ${error.message}`);
215
+ throw new Error(`Failed to get PR changed files: ${error.message}`);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Find base reference for the given branch name
221
+ * @param {string} baseBranch - Base branch name
222
+ * @param {string} gitRoot - Git repository root path
223
+ * @returns {string|null} Base reference or null if not found
224
+ */
225
+ static findBaseRef(baseBranch, gitRoot) {
226
+ const candidates = [
227
+ `origin/${baseBranch}`,
228
+ `upstream/${baseBranch}`,
229
+ baseBranch
230
+ ];
231
+
232
+ for (const candidate of candidates) {
233
+ try {
234
+ execSync(`git rev-parse --verify ${candidate}`, {
235
+ cwd: gitRoot,
236
+ stdio: 'ignore'
237
+ });
238
+ return candidate;
239
+ } catch (error) {
240
+ // Continue to next candidate
241
+ }
242
+ }
243
+
244
+ return null;
245
+ }
246
+
247
+ /**
248
+ * Ensure base ref exists (fetch if necessary)
249
+ * @param {string} baseRef - Base reference
250
+ * @param {string} gitRoot - Git repository root path
251
+ */
252
+ static ensureBaseRefExists(baseRef, gitRoot) {
253
+ try {
254
+ // Check if ref exists
255
+ execSync(`git rev-parse --verify ${baseRef}`, {
256
+ cwd: gitRoot,
257
+ stdio: 'ignore'
258
+ });
259
+ } catch (error) {
260
+ // Try to fetch if it doesn't exist
261
+ const remote = baseRef.split('/')[0];
262
+ if (remote === 'origin' || remote === 'upstream') {
263
+ try {
264
+ console.log(`Fetching ${remote}...`);
265
+ execSync(`git fetch ${remote} --depth=1`, {
266
+ cwd: gitRoot,
267
+ stdio: 'inherit'
268
+ });
269
+ } catch (fetchError) {
270
+ console.warn(`Warning: Failed to fetch ${remote}: ${fetchError.message}`);
271
+ }
272
+ }
49
273
  }
50
274
  }
51
275
 
@@ -182,14 +182,56 @@ function readJsonFile(jsonFile) {
182
182
  }
183
183
  }
184
184
 
185
+ /**
186
+ * Get git root directory
187
+ * @param {string} cwd - Current working directory
188
+ * @returns {string} Git root path
189
+ */
190
+ function getGitRoot(cwd = process.cwd()) {
191
+ try {
192
+ const { execSync } = require('child_process');
193
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
194
+ cwd,
195
+ encoding: 'utf8'
196
+ }).trim();
197
+ return gitRoot;
198
+ } catch (error) {
199
+ logger.warn('Not a git repository, using cwd as root');
200
+ return cwd;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Normalize path to be relative from git root
206
+ * @param {string} filePath - File path (absolute or relative)
207
+ * @param {string} gitRoot - Git root directory
208
+ * @returns {string} Normalized relative path
209
+ */
210
+ function normalizePathFromGitRoot(filePath, gitRoot) {
211
+ let normalized = filePath;
212
+
213
+ // Convert absolute path to relative from git root
214
+ if (filePath.startsWith(gitRoot)) {
215
+ normalized = filePath.slice(gitRoot.length);
216
+ if (normalized.startsWith('/') || normalized.startsWith('\\')) {
217
+ normalized = normalized.slice(1);
218
+ }
219
+ }
220
+
221
+ // Normalize path separators to forward slash
222
+ normalized = normalized.replace(/\\/g, '/');
223
+
224
+ return normalized;
225
+ }
226
+
185
227
  /**
186
228
  * Parse violations from JSON data
187
229
  * @param {Array|Object} raw - Raw JSON data
230
+ * @param {string} gitRoot - Git root directory for path normalization
188
231
  * @returns {Array} Array of violation objects
189
232
  */
190
- function parseViolations(raw) {
233
+ function parseViolations(raw, gitRoot) {
191
234
  const violations = [];
192
- const cwd = process.env.GITHUB_WORKSPACE || process.cwd();
193
235
 
194
236
  if (Array.isArray(raw)) {
195
237
  for (const fileObj of raw) {
@@ -203,18 +245,8 @@ function parseViolations(raw) {
203
245
  continue;
204
246
  }
205
247
 
206
- let relPath = fileObj.filePath;
207
-
208
- // Convert absolute path to relative
209
- if (relPath.startsWith(cwd)) {
210
- relPath = relPath.slice(cwd.length);
211
- if (relPath.startsWith('/') || relPath.startsWith('\\')) {
212
- relPath = relPath.slice(1);
213
- }
214
- }
215
-
216
- // Normalize path separators
217
- relPath = relPath.replace(/\\/g, '/');
248
+ // Normalize path relative to git root (same as GitHub API)
249
+ const relPath = normalizePathFromGitRoot(fileObj.filePath, gitRoot);
218
250
 
219
251
  for (const msg of fileObj.messages) {
220
252
  if (!msg || typeof msg !== 'object') {
@@ -243,7 +275,12 @@ function parseViolations(raw) {
243
275
  if (!Array.isArray(rawViolations)) {
244
276
  throw new Error('violations property must be an array');
245
277
  }
246
- violations.push(...rawViolations);
278
+
279
+ // Normalize paths for raw violations too
280
+ violations.push(...rawViolations.map(v => ({
281
+ ...v,
282
+ file: normalizePathFromGitRoot(v.file, gitRoot)
283
+ })));
247
284
  } else {
248
285
  throw new Error('JSON data must be an array or object with violations property');
249
286
  }
@@ -521,9 +558,13 @@ async function annotate({
521
558
  logger.info('Reading result file', { jsonFile });
522
559
  const raw = readJsonFile(jsonFile);
523
560
 
524
- // Step 3: Parse violations
561
+ // Step 2.5: Get git root for path normalization
562
+ const gitRoot = getGitRoot();
563
+ logger.debug('Git root directory', { gitRoot });
564
+
565
+ // Step 3: Parse violations with git root normalization
525
566
  logger.info('Parsing violations');
526
- const violations = parseViolations(raw);
567
+ const violations = parseViolations(raw, gitRoot);
527
568
 
528
569
  if (violations.length === 0) {
529
570
  logger.info('No violations found');
@@ -607,6 +648,14 @@ async function annotate({
607
648
  let linesSkipped = 0;
608
649
  let renamedFilesHandled = 0;
609
650
 
651
+ // Debug: Log sample paths for comparison
652
+ logger.debug('Path comparison debug:', {
653
+ sampleViolationFiles: violations.slice(0, 3).map(v => v.file),
654
+ samplePRFiles: Array.from(prFilesInfo.keys()).slice(0, 3),
655
+ totalViolations: violations.length,
656
+ totalPRFiles: prFilesInfo.size
657
+ });
658
+
610
659
  for (const v of violations) {
611
660
  let targetFile = v.file;
612
661
  let fileInfo = prFilesInfo.get(targetFile);
@@ -813,9 +862,13 @@ async function postSummaryComment({
813
862
  logger.info('Reading result file', { jsonFile });
814
863
  const raw = readJsonFile(jsonFile);
815
864
 
816
- // Step 3: Parse violations
865
+ // Step 2.5: Get git root for path normalization
866
+ const gitRoot = getGitRoot();
867
+ logger.debug('Git root directory', { gitRoot });
868
+
869
+ // Step 3: Parse violations with git root normalization
817
870
  logger.info('Parsing violations for summary');
818
- const violations = parseViolations(raw);
871
+ const violations = parseViolations(raw, gitRoot);
819
872
 
820
873
  // Step 4: Initialize Octokit
821
874
  const token = githubToken || process.env.GITHUB_TOKEN;
@@ -28,9 +28,9 @@ class OutputService {
28
28
  try {
29
29
  const packageJsonPath = path.join(__dirname, '..', 'package.json');
30
30
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
31
- return packageJson.version || '1.3.18';
31
+ return packageJson.version || '1.3.21';
32
32
  } catch (error) {
33
- return '1.3.18'; // Fallback version
33
+ return '1.3.21'; // Fallback version
34
34
  }
35
35
  }
36
36
 
@@ -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,28 @@ 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 và sử dụng merge-base để diff chính xác!
94
96
 
95
97
  ```yaml
96
- - name: Run SunLint on Changed Files
98
+ - name: Run SunLint on Changed Files (Auto-detect)
97
99
  run: sunlint --all --changed-files --github-annotate
98
100
  env:
99
101
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
100
102
  ```
101
103
 
104
+ **Cách hoạt động**:
105
+ - ✅ Tự động detect GitHub Actions PR event (`GITHUB_EVENT_NAME=pull_request`)
106
+ - ✅ Tự động lấy base branch từ `GITHUB_BASE_REF`
107
+ - ✅ Sử dụng `git merge-base` để tìm common ancestor
108
+ - ✅ So sánh với merge-base thay vì HEAD → Lấy đúng các file thay đổi trong PR
109
+ - ✅ Tự động fetch base branch nếu cần
110
+
111
+ **Không cần chỉ định `--diff-base` nữa!** SunLint sẽ tự động xử lý.
112
+
113
+ **Fallback**: Nếu không phát hiện được PR context, sẽ fallback về logic cũ (so sánh với HEAD hoặc origin/main)
114
+
102
115
  ### 2. Save report file + annotate
103
116
 
104
117
  ```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.20",
3
+ "version": "1.3.22",
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": {