Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/src/baseline/baseline_entry.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// An entry in the baseline file.
class BaselineEntry {
BaselineEntry({
required this.ruleId,
required this.file,
required this.line,
required this.fingerprint,
});

/// The rule ID.
final String ruleId;

/// The file path.
final String file;

/// The line number.
final int line;

/// The fingerprint (hash of rule, file, line).
final String fingerprint;
}
116 changes: 116 additions & 0 deletions lib/src/baseline/baseline_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:io';

import 'package:crypto/crypto.dart';
import 'package:dart_shield/src/baseline/baseline_entry.dart';
import 'package:dart_shield/src/domain/analysis_issue.dart';
import 'package:dart_shield/src/domain/issue_context.dart';
import 'package:yaml/yaml.dart';

/// Manages baseline files for tracking known issues.
///
/// Baseline files allow teams to adopt dart_shield in existing projects
/// without being overwhelmed by legacy issues. The tool records all current
/// issues, then only reports new violations.
class BaselineManager {
BaselineManager(this.baselinePath);

/// Path to the baseline file.
final String baselinePath;

/// Create a baseline file from the given issues.
Future<void> createBaseline(List<AnalysisIssue> issues) async {
final entries = issues.map((issue) {
final context = issue.context;
final filePath = switch (context) {
FileContext(:final filePath) => filePath,
};
final line = switch (context) {
FileContext(:final line) => line,
};

return {
'rule_id': issue.ruleId,
'file': filePath,
'line': line,
'fingerprint': generateFingerprint(issue),
};
}).toList();

final yamlContent = _generateYaml(entries);

final file = File(baselinePath);
await file.writeAsString(yamlContent);
}

/// Load the baseline entries from the file.
Future<List<BaselineEntry>> loadBaseline() async {
final file = File(baselinePath);
if (!file.existsSync()) return [];

final content = await file.readAsString();
if (content.trim().isEmpty) return [];

final yaml = loadYaml(content);
if (yaml == null) return [];

final yamlMap = yaml as YamlMap;
final baseline = yamlMap['baseline'];
if (baseline == null) return [];

final baselineList = baseline as YamlList;
return baselineList.map((entry) {
final entryMap = entry as YamlMap;
return BaselineEntry(
ruleId: entryMap['rule_id'] as String,
file: entryMap['file'] as String,
line: entryMap['line'] as int,
fingerprint: entryMap['fingerprint'] as String,
);
}).toList();
}

/// Filter out issues that are already in the baseline.
Future<List<AnalysisIssue>> filterBaselined(List<AnalysisIssue> issues) async {
final baseline = await loadBaseline();
final baselineFingerprints = baseline.map((e) => e.fingerprint).toSet();

return issues.where((issue) {
final fp = generateFingerprint(issue);
return !baselineFingerprints.contains(fp);
}).toList();
}

/// Generate a stable fingerprint for an issue.
///
/// The fingerprint is based on the rule ID, file path, and line number.
/// This allows the baseline to track issues even if the message changes.
String generateFingerprint(AnalysisIssue issue) {
final context = issue.context;
final filePath = switch (context) {
FileContext(:final filePath) => filePath,
};
final line = switch (context) {
FileContext(:final line) => line,
};

final data = '${issue.ruleId}:$filePath:$line';
return md5.convert(utf8.encode(data)).toString().substring(0, 12);
}

/// Generate YAML content from baseline entries.
String _generateYaml(List<Map<String, dynamic>> entries) {
if (entries.isEmpty) {
return 'baseline: []\n';
}

final buffer = StringBuffer('baseline:\n');
for (final entry in entries) {
buffer.writeln(' - rule_id: ${entry['rule_id']}');
buffer.writeln(' file: ${entry['file']}');
buffer.writeln(' line: ${entry['line']}');
buffer.writeln(' fingerprint: ${entry['fingerprint']}');
}
return buffer.toString();
}
}
2 changes: 2 additions & 0 deletions lib/src/cli/command_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:cli_completion/cli_completion.dart';
import 'package:dart_shield/src/cli/commands/analyze_command.dart';
import 'package:dart_shield/src/cli/commands/baseline_command.dart';
import 'package:dart_shield/src/cli/commands/init_command.dart';
import 'package:mason_logger/mason_logger.dart';

Expand All @@ -15,6 +16,7 @@ class ShieldCommandRunner extends CompletionCommandRunner<int> {
super(executableName, description) {
// Add sub commands
addCommand(AnalyzeCommand(logger: _logger));
addCommand(BaselineCommand(logger: _logger));
addCommand(InitCommand(logger: _logger));
}

Expand Down
8 changes: 8 additions & 0 deletions lib/src/cli/commands/analyze_command.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dart_shield/src/cli/commands/baseline_command.dart';
import 'package:dart_shield/src/cli/commands/shield_command.dart';
import 'package:dart_shield/src/core/analyzer_factory.dart';
import 'package:dart_shield/src/core/shield_run_config.dart';
Expand Down Expand Up @@ -29,6 +30,12 @@ class AnalyzeCommand extends ShieldCommand {
'exclude',
allowed: AnalyzerFactory.availableIds,
help: 'Exclude specific analyzers.',
)
..addOption(
'baseline',
abbr: 'b',
help: 'Path to baseline file. Issues in baseline are not reported. '
'Default location is $defaultBaselinePath',
);
}

Expand All @@ -48,6 +55,7 @@ class AnalyzeCommand extends ShieldCommand {
exclude: argResults['exclude'] as List<String>? ?? [],
reporterMode: argResults['reporter'] as String? ?? 'console',
minSeverity: ShieldRunConfig.parseSeverity(severityStr),
baselinePath: argResults['baseline'] as String?,
);

final runner = ShieldRunner(logger: logger);
Expand Down
112 changes: 112 additions & 0 deletions lib/src/cli/commands/baseline_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import 'dart:io';

import 'package:dart_shield/src/baseline/baseline_manager.dart';
import 'package:dart_shield/src/cli/commands/shield_command.dart';
import 'package:dart_shield/src/configuration/shield_config.dart';
import 'package:dart_shield/src/core/analyzer_engine.dart';
import 'package:dart_shield/src/core/analyzer_factory.dart';
import 'package:dart_shield/src/domain/analyzer_result.dart';
import 'package:mason_logger/mason_logger.dart';

/// Default baseline file path following Dart conventions.
const defaultBaselinePath = '.dart_tool/dart_shield/baseline.yaml';

/// Command to create or update a baseline file for existing issues.
///
/// This allows teams to adopt dart_shield in existing projects without
/// being overwhelmed by legacy issues.
class BaselineCommand extends ShieldCommand {
BaselineCommand({super.logger}) {
argParser
..addOption(
'output',
abbr: 'o',
defaultsTo: defaultBaselinePath,
help: 'Output path for the baseline file. '
'Defaults to $defaultBaselinePath',
)
..addFlag(
'update',
abbr: 'u',
help: 'Update existing baseline with current issues.',
);
}

@override
String get name => 'baseline';

@override
String get description =>
'Create or update a baseline file for existing issues.';

@override
Future<int> run() async {
final outputPath = argResults['output'] as String;
final update = argResults['update'] as bool;
final paths = argResults.rest.isEmpty ? ['.'] : argResults.rest;

try {
// Check if baseline exists when not updating
final checkFile = File(outputPath);
if (checkFile.existsSync() && !update) {
logger.err(
'Baseline file already exists: $outputPath\n'
'Use --update to merge with existing baseline.',
);
return ExitCode.usage.code;
}

// Load configuration
final config = await ShieldConfig.load();

// Resolve analyzers
final analyzers = AnalyzerFactory.resolve(
paths: paths,
config: config,
only: [],
exclude: [],
);

if (analyzers.isEmpty) {
logger.warn('No analyzers selected.');
return ExitCode.config.code;
}

// Run analysis
logger
..info('🛡️ Running analysis to create baseline...')
..info(' Analyzing ${paths.join(', ')}');

final engine = AnalyzerEngine(analyzers);
final results = await engine.runAll();

// Extract all issues
final issues = results
.whereType<AnalysisSuccess>()
.expand((r) => r.issues)
.toList();

// Ensure parent directory exists
final baselineFile = File(outputPath);
final parentDir = baselineFile.parent;
if (!parentDir.existsSync()) {
parentDir.createSync(recursive: true);
}

// Create baseline
final manager = BaselineManager(outputPath);
await manager.createBaseline(issues);

logger
..success('✅ Baseline created: $outputPath')
..info(' ${issues.length} issues recorded');

return ExitCode.success.code;
} on Object catch (e, stack) {
logger
..err('Failed to create baseline: $e')
..detail('$stack');
return ExitCode.software.code;
}
}
}
5 changes: 5 additions & 0 deletions lib/src/core/shield_run_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class ShieldRunConfig {
this.exclude = const [],
this.reporterMode = 'console',
this.minSeverity = Severity.info,
this.baselinePath,
});

final List<String> paths;
Expand All @@ -18,6 +19,10 @@ class ShieldRunConfig {
/// Issues below this severity level will be filtered out.
final Severity minSeverity;

/// Path to the baseline file.
/// Issues in the baseline will be filtered out.
final String? baselinePath;

/// Parses a severity string to the corresponding [Severity] enum.
/// Returns [Severity.info] if the string is not recognized.
static Severity parseSeverity(String value) {
Expand Down
6 changes: 6 additions & 0 deletions lib/src/core/shield_runner.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dart_shield/src/baseline/baseline_manager.dart';
import 'package:dart_shield/src/configuration/shield_config.dart';
import 'package:dart_shield/src/core/analyzer_engine.dart';
import 'package:dart_shield/src/core/analyzer_factory.dart';
Expand Down Expand Up @@ -91,6 +92,11 @@ class ShieldRunner {
// 5. Filter by minimum severity
results = _filterBySeverity(results, runConfig.minSeverity);

// 5.5. Filter baselined issues
if (runConfig.baselinePath != null) {
results = await _filterBaselined(results, runConfig.baselinePath!);
}

// 6. Reporting
final reporters = _getReporters(runConfig.reporterMode);
await Future.wait(reporters.map((r) => r.report(results)));
Expand Down
Loading