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
2 changes: 2 additions & 0 deletions tool/dart_skills_lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ If no directory is specified, it automatically checks `.claude/skills` and `.age
- `--fast-fail`: Halt execution immediately on the error.
- `--ignore-config`: Ignore the YAML configuration file entirely.
- `--[no-]check-trailing-whitespace`: Enable/disable checking for trailing whitespace. (Disabled by default).
- `--fix`: Preview fixes for failing lints (dry run).
- `--fix-apply`: Apply fixes for failing lints.

### 2. As a Command Line Tool with a YAML Configuration File
You can configure the linter using a configuration file (defaulting to `dart_skills_lint.yaml` in the current directory).
Expand Down
172 changes: 164 additions & 8 deletions tool/dart_skills_lint/lib/src/entry_point.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;

import 'config_parser.dart';
import 'fixable_rule.dart';
import 'models/analysis_severity.dart';
import 'models/check_type.dart';
import 'models/ignore_entry.dart';
import 'models/skill_context.dart';
import 'models/skill_rule.dart';
import 'models/skills_ignores.dart';
import 'models/validation_error.dart';
import 'rule_registry.dart';

import 'skills_ignores_storage.dart';
import 'validator.dart';

Expand All @@ -32,6 +33,8 @@ const _skillOption = 'skill';
const _ignoreFileOption = 'ignore-file';
const _ignoreConfigFlag = 'ignore-config';
const _generateBaselineFlag = 'generate-baseline';
const _fixFlag = 'fix';
const _fixApplyFlag = 'fix-apply';

@visibleForTesting
const defaultIgnoreFileName = 'dart_skills_lint_ignore.json';
Expand Down Expand Up @@ -93,7 +96,9 @@ Future<void> runApp(List<String> args) async {
negatable: false,
help: 'Write all current errors into $defaultIgnoreFileName to ignore on future runs.')
..addFlag(_ignoreConfigFlag,
negatable: false, help: 'Ignore the YAML configuration file entirely.');
negatable: false, help: 'Ignore the YAML configuration file entirely.')
..addFlag(_fixFlag, negatable: false, help: 'Preview fixes for failing lints (dry run).')
..addFlag(_fixApplyFlag, negatable: false, help: 'Apply fixes for failing lints.');

final ArgResults results;
try {
Expand Down Expand Up @@ -143,20 +148,24 @@ Future<void> runApp(List<String> args) async {
final fastFail = results[_fastFailFlag] as bool;
final quiet = results[_quietFlag] as bool;
final generateBaseline = results[_generateBaselineFlag] as bool;
final fix = results[_fixFlag] as bool;
final fixApply = results[_fixApplyFlag] as bool;

String? ignoreFileOverride;
if (results.wasParsed(_ignoreFileOption)) {
ignoreFileOverride = results[_ignoreFileOption] as String?;
}

final bool success = await validateSkills(
final bool success = await validateSkillsInternal(
skillDirPaths: skillDirPaths,
individualSkillPaths: individualSkillPaths,
resolvedRules: resolvedRules,
printWarnings: printWarnings,
fastFail: fastFail,
quiet: quiet,
generateBaseline: generateBaseline,
fix: fix,
fixApply: fixApply,
ignoreFileOverride: ignoreFileOverride,
config: config,
);
Expand All @@ -166,6 +175,9 @@ Future<void> runApp(List<String> args) async {

/// Validates skills based on the provided configuration.
///
/// This is the public API for validating skills. It does not support fixing
/// lints as that feature is currently considered internal to the CLI.
///
/// [skillDirPaths] is a list of directories containing multiple skills.
/// [individualSkillPaths] is a list of paths to individual skill directories.
/// [resolvedRules] is a map of rule names to their severity overrides.
Expand All @@ -188,6 +200,38 @@ Future<bool> validateSkills({
String? ignoreFileOverride,
Configuration? config,
List<SkillRule> customRules = const [],
}) async {
return validateSkillsInternal(
skillDirPaths: skillDirPaths,
individualSkillPaths: individualSkillPaths,
resolvedRules: resolvedRules,
printWarnings: printWarnings,
fastFail: fastFail,
quiet: quiet,
generateBaseline: generateBaseline,
ignoreFileOverride: ignoreFileOverride,
config: config,
customRules: customRules,
);
}

/// Internal implementation of skill validation that supports fixing.
///
/// Kept internal to avoid exposing experimental fix parameters in the public API.
@visibleForTesting
Future<bool> validateSkillsInternal({
List<String> skillDirPaths = const [],
List<String> individualSkillPaths = const [],
Map<String, AnalysisSeverity> resolvedRules = const {},
bool printWarnings = true,
bool fastFail = false,
bool quiet = false,
bool generateBaseline = false,
bool fix = false,
bool fixApply = false,
String? ignoreFileOverride,
Configuration? config,
List<SkillRule> customRules = const [],
}) async {
config ??= Configuration();
var globalAnyFailed = false;
Expand Down Expand Up @@ -231,8 +275,18 @@ Future<bool> validateSkills({
quiet: quiet,
);

final ValidationResult finalResult = await _applyFixesIfNeeded(
skillDir: skillDir,
result: result,
validator: validator,
skillIgnores: skillIgnores,
fix: fix,
fixApply: fixApply,
quiet: quiet,
);

if (generateBaseline) {
await _generateBaselineFile(result, localIgnoreFile, skillDir, skillDir);
await _generateBaselineFile(finalResult, localIgnoreFile, skillDir, skillDir);
}

if (!generateBaseline) {
Expand All @@ -245,7 +299,7 @@ Future<bool> validateSkills({
}
}

if (!result.isValid) {
if (!finalResult.isValid) {
globalAnyFailed = true;
if (fastFail) {
break;
Expand Down Expand Up @@ -304,11 +358,21 @@ Future<bool> validateSkills({
quiet: quiet,
);

final ValidationResult finalResult = await _applyFixesIfNeeded(
skillDir: entity,
result: result,
validator: validator,
skillIgnores: ignoresMap[p.basename(entity.path)] ?? [],
fix: fix,
fixApply: fixApply,
quiet: quiet,
);

if (generateBaseline) {
await _generateBaselineFile(result, localIgnoreFile, rootDir, entity);
await _generateBaselineFile(finalResult, localIgnoreFile, rootDir, entity);
}

if (!result.isValid) {
if (!finalResult.isValid) {
globalAnyFailed = true;
if (fastFail) {
break;
Expand Down Expand Up @@ -339,7 +403,7 @@ Future<bool> validateSkills({
var foundSingleSkillPassedToD = false;
for (final rootPath in skillDirPaths) {
final String expandedRootPath = _expandPath(rootPath);
final skillMdFile = File(p.join(expandedRootPath, 'SKILL.md'));
final skillMdFile = File(p.join(expandedRootPath, SkillContext.skillFileName));
if (skillMdFile.existsSync()) {
_log.severe(
'Directory "$expandedRootPath" appears to be an individual skill. Use --skill / -s instead of -d / --skills-directory.');
Expand Down Expand Up @@ -483,6 +547,98 @@ Future<ValidationResult> _validateSingleSkill({
return result;
}

Future<ValidationResult> _applyFixesIfNeeded({
required Directory skillDir,
required ValidationResult result,
required Validator validator,
required List<IgnoreEntry> skillIgnores,
required bool fix,
required bool fixApply,
required bool quiet,
}) async {
if (!fix && !fixApply) {
return result;
}

final SkillContext? context = result.context;
if (context == null) {
return result;
}

final String skillName = p.basename(skillDir.path);
final skillMdFile = File(p.join(skillDir.path, SkillContext.skillFileName));
if (!skillMdFile.existsSync()) {
return result;
}

String currentContent = context.rawContent;
final originalContent = currentContent;
var modified = false;

for (final SkillRule rule in validator.rules) {
if (rule is FixableRule) {
final bool hasErrors =
result.validationErrors.any((e) => e.ruleId == rule.name && !e.isIgnored);
if (hasErrors) {
try {
final String newContent = await (rule as FixableRule)
.fix(SkillContext.skillFileName, currentContent, context.directory);
if (newContent != currentContent) {
currentContent = newContent;
modified = true;
}
} catch (e) {
_log.severe(" Failed to apply fix for rule '${rule.name}': $e");
}
}
}
}

if (modified) {
if (fixApply) {
await skillMdFile.writeAsString(currentContent);
if (!quiet) {
_log.info(' Applied fixes for $skillName');
}
final ValidationResult newResult = await validator.validate(skillDir);
_applyIgnores(newResult, skillIgnores, skillDir);
return newResult;
} else if (fix) {
if (!quiet) {
_log.info(' [Dry Run] Proposed changes for $skillName (SKILL.md):');
_printDiff(originalContent, currentContent);
}
}
}

return result;
}

/// Prints a simple line-by-line diff between [original] and [modified].
///
/// **Limitation**: This naive diff algorithm does not handle line additions or
/// removals well, as it compares lines at the same index. It is sufficient for
/// current fixers that only modify existing lines, but should be replaced with
/// a more robust diffing solution (e.g., `package:diff`) if future fixers
/// add or remove lines.
void _printDiff(String original, String modified) {
final List<String> origLines = original.split('\n');
final List<String> modLines = modified.split('\n');
final int maxLines = origLines.length > modLines.length ? origLines.length : modLines.length;
for (var i = 0; i < maxLines; i++) {
final String orig = i < origLines.length ? origLines[i] : '';
final String mod = i < modLines.length ? modLines[i] : '';
if (orig != mod) {
if (orig.isNotEmpty) {
_log.info('- Line ${i + 1}: $orig');
}
if (mod.isNotEmpty) {
_log.info('+ Line ${i + 1}: $mod');
}
}
}
}
Comment thread
reidbaker marked this conversation as resolved.
Comment thread
reidbaker marked this conversation as resolved.

Future<void> _generateBaselineFile(ValidationResult result, String? ignoreFileOverride,
Directory rootDir, Directory skillDir) async {
final String ignorePath = ignoreFileOverride != null
Expand Down
19 changes: 19 additions & 0 deletions tool/dart_skills_lint/lib/src/fixable_rule.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';

/// Interface for rules that support fixes.
/// Kept internal to the package for now.
abstract class FixableRule {
/// Returns the updated content of the file at [filePath].
/// [currentContent] is the content after previous fixes have been applied.
/// If the rule does not support fixing the file at [filePath], it should return [currentContent].
///
/// Rules should rely on [currentContent] for the current state of the file.
/// If a rule needs structured access (like parsed YAML), it should parse
/// [currentContent] itself, as structured data in [SkillContext] may be stale
/// if previous rules applied fixes.
Future<String> fix(String filePath, String currentContent, Directory directory);
}
6 changes: 6 additions & 0 deletions tool/dart_skills_lint/lib/src/models/skill_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class SkillContext {
this.yamlParsingError,
});

/// The required filename for skill documentation.
static const String skillFileName = 'SKILL.md';

/// Regex to match the YAML frontmatter in SKILL.md.
static final RegExp skillStartRegex = RegExp(r'^---\s*\n(.*?)\n---\s*\n', dotAll: true);

final Directory directory;

/// Guaranteed to be non-null because we only run rules if SKILL.md exists.
Expand Down
28 changes: 26 additions & 2 deletions tool/dart_skills_lint/lib/src/rules/absolute_paths_rule.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import 'dart:io';
import 'package:path/path.dart';
import '../fixable_rule.dart';
import '../models/analysis_severity.dart';
import '../models/skill_context.dart';
import '../models/skill_rule.dart';
import '../models/validation_error.dart';

/// Enforces that links in SKILL.md do not use absolute paths.
class AbsolutePathsRule extends SkillRule {
class AbsolutePathsRule extends SkillRule implements FixableRule {
AbsolutePathsRule({this.severity = defaultSeverity});

static const String ruleName = 'check-absolute-paths';
Expand All @@ -18,7 +20,7 @@ class AbsolutePathsRule extends SkillRule {
final AnalysisSeverity severity;

static final _markdownLinkRegex = RegExp(r'\[.*?\]\((.*?)\)');
static const _skillFileName = 'SKILL.md';
static const String _skillFileName = SkillContext.skillFileName;

@override
Future<List<ValidationError>> validate(SkillContext context) async {
Expand All @@ -44,4 +46,26 @@ class AbsolutePathsRule extends SkillRule {

return errors;
}

@override
Future<String> fix(String filePath, String currentContent, Directory directory) async {
if (filePath != SkillContext.skillFileName) {
return currentContent;
}

return currentContent.replaceAllMapped(_markdownLinkRegex, (match) {
final String path = match.group(1)!;
if (isAbsolute(path) || windows.isAbsolute(path)) {
final file = File(path);
if (file.existsSync()) {
final String relativePath = relative(path, from: directory.path);
final String posixRelativePath = relativePath.replaceAll(r'\', '/');
final String fullMatch = match.group(0)!;
final int lastParen = fullMatch.lastIndexOf('(');
return '${fullMatch.substring(0, lastParen + 1)}$posixRelativePath)';
}
}
return match.group(0)!;
});
}
Comment thread
reidbaker marked this conversation as resolved.
}
Loading
Loading