Skip to content
Open
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
87 changes: 87 additions & 0 deletions bin/validate_rfc_number.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 The Flutter Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io';
import 'package:args/args.dart';
import 'package:file/local.dart';
import 'package:rfc_tools/src/validator.dart';

void main(List<String> arguments) async {
final parser = ArgParser()
..addOption(
'base-branch',
defaultsTo: 'origin/main',
help: 'Base git branch to check against for collisions.',
)
..addFlag(
'check-main',
negatable: false,
help: 'Check for number collisions against the base git branch.',
)
..addFlag(
'no-drafts',
negatable: false,
help:
'Reject any ".0000" draft RFCs (required for Merge Queue and main).',
)
..addFlag(
'github-actions',
negatable: false,
help:
'Output errors in GitHub Actions annotation format (::error file=...::).',
)
..addFlag(
'help',
abbr: 'h',
negatable: false,
help: 'Show usage instructions.',
);

ArgResults results;
try {
results = parser.parse(arguments);
} catch (e) {
stderr.writeln('Error parsing arguments: $e\n');
stderr.writeln(parser.usage);
exitCode = 1;
return;
}

if (results.flag('help')) {
stdout.writeln('RFC Semantic Validator - Flutter RFC Repository Tooling\n');
stdout.writeln(parser.usage);
return;
}

final checkMain = results.flag('check-main');
final baseBranch = results.option('base-branch')!;
final noDrafts = results.flag('no-drafts');
final githubActions = results.flag('github-actions');

const fs = LocalFileSystem();
final validator = RfcValidator(fs: fs);

final (:isSuccess, :errors) = await validator.validate(
noDrafts: noDrafts,
checkMain: checkMain,
baseBranch: baseBranch,
);

if (!isSuccess) {
stderr.writeln('RFC validation failed with ${errors.length} error(s):\n');
for (final error in errors) {
if (githubActions) {
stderr.writeln(error.toGithubAnnotation());
} else {
stderr.writeln('[ERROR] $error');
}
}
exitCode = 1;
return;
}

stdout.writeln(
'RFC numbers validated cleanly. No collisions or illegal drafts found.',
);
}
6 changes: 2 additions & 4 deletions lib/src/git_lister.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ import 'dart:io';
import 'github_client.dart' show ProcessRunner;

/// Signature for querying RFC files on a remote/base git branch.
typedef GitListFunction =
Future<Set<String>> Function({String baseBranch, String rfcDir});
typedef GitListFunction = Future<Set<String>> Function({String baseBranch});

/// Default implementation querying git via `git ls-tree`.
Future<Set<String>> defaultGitList({
String baseBranch = 'origin/main',
String rfcDir = 'rfc',
ProcessRunner processRunner = Process.run,
}) async {
try {
Expand All @@ -23,7 +21,7 @@ Future<Set<String>> defaultGitList({
'--name-only',
baseBranch,
'--',
'$rfcDir/',
'rfc/',
]);
if (result.exitCode != 0) {
stdout.writeln('exit code: ${result.exitCode}');
Expand Down
53 changes: 53 additions & 0 deletions lib/src/github_annotation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2026 The Flutter Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/// Interface for objects that can be formatted as GitHub Actions workflow annotations.
abstract interface class GithubAnnotatable {
/// Formats this object as a GitHub Actions workflow annotation string.
String toGithubAnnotation();
}

/// Extension on [String] for GitHub Actions workflow command formatting.
extension GithubAnnotationExtension on String {
/// Encodes special characters (`%`, `\r`, `\n`) in this string per GitHub Actions
/// workflow command specifications so multiline formatting is preserved.
String toGithubWorkflowValue() {
return replaceAll(
'%',
'%25',
).replaceAll('\r', '%0D').replaceAll('\n', '%0A');
}

/// Formats this string message as a GitHub Actions workflow annotation.
///
/// Example:
/// ```dart
/// 'File not found'.toGithubAnnotation(filePath: 'rfc/110.0001.md');
/// => '::error file=rfc/110.0001.md::File not found'
///
/// 'Syntax error'.toGithubAnnotation(
/// filePath: 'rfc/110.0001.md',
/// line: 12,
/// column: 4,
/// );
/// => '::error file=rfc/110.0001.md,line=12,col=4::Syntax error'
/// ```
String toGithubAnnotation({
required String filePath,
int? line,
int? column,
String type = 'error',
String? title,
}) {
final encoded = toGithubWorkflowValue();
final params = <String>[
'file=$filePath',
if (line != null) 'line=$line',
if (column != null) 'col=$column',
if (title != null) 'title=$title',
].join(',');

return '::$type $params::$encoded';
}
}
16 changes: 8 additions & 8 deletions lib/src/linter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import 'package:file/file.dart';
import 'package:path/path.dart' as p;

import 'github_annotation.dart';
import 'github_client.dart';
import 'models/rfc_file.dart';
import 'taxonomy.dart';

/// A lint issue discovered in an RFC document.
class LintIssue {
class LintIssue implements GithubAnnotatable {
final String filePath;
final int line;
final int column;
Expand All @@ -27,13 +28,12 @@ class LintIssue {
///
/// Percent-encodes special characters (%, \r, \n) per GitHub Actions workflow
/// command specifications so multiline schema templates are preserved cleanly.
String toGithubAnnotation() {
final encoded = message
.replaceAll('%', '%25')
.replaceAll('\r', '%0D')
.replaceAll('\n', '%0A');
return '::error file=$filePath,line=$line,col=$column::$encoded';
}
@override
String toGithubAnnotation() => message.toGithubAnnotation(
filePath: filePath,
line: line,
column: column,
);

@override
String toString() => '$filePath:$line:$column: $message';
Expand Down
30 changes: 30 additions & 0 deletions lib/src/models/rfc_file.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,31 @@ class RfcFile {
required this.headingError,
});

/// Creates an [RfcFile] from a file path with parsed filename components,
/// without reading or parsing markdown content or frontmatter.
///
/// Useful for lightweight filename-based validation.
factory RfcFile.fromPath(String path) {
final parsed = _parseFilename(path);
return RfcFile._(
path: path,
category: parsed.category,
index: parsed.index,
slug: parsed.slug,
hasFrontmatter: false,
frontmatterRaw: '',
frontmatter: null,
frontmatterError: null,
frontmatterErrors: const [],
body: '',
firstHeading: null,
firstHeadingId: null,
firstHeadingTitle: null,
firstHeadingLine: null,
headingError: null,
);
}

/// Regular expression for RFC filenames: `AAA.NNNN-<slug>.md`.
static final RegExp filenamePattern = RegExp(
r'^(\d{3})\.(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$',
Expand Down Expand Up @@ -134,6 +159,11 @@ class RfcFile {
bool get hasValidHeading =>
headingError == null && firstHeading != null && firstHeadingId != null;

/// Extracts category, index, and slug from an RFC file path.
static ({String? category, int? index, String? slug}) parseFilename(
String path,
) => _parseFilename(path);

/// Extracts category, index, and slug from an RFC file path.
static ({String? category, int? index, String? slug}) _parseFilename(
String path,
Expand Down
Loading
Loading