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
33 changes: 19 additions & 14 deletions bin/rfc_lint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'dart:io';
import 'package:args/args.dart';
import 'package:file/local.dart';
import 'package:rfc_tools/src/git_lister.dart';
import 'package:rfc_tools/src/github_client.dart';
import 'package:rfc_tools/src/linter.dart';
import 'package:rfc_tools/src/taxonomy.dart';

Expand All @@ -21,17 +22,17 @@ void main(List<String> arguments) async {
help:
'Enforce that RFCs under review must use ".0000" unless labeled with "rfc-ready" or "rfc-assigned".',
)
..addFlag(
'validate-github-users',
negatable: false,
help: 'Verify that GitHub profile authors exist via the GitHub API.',
)
..addFlag(
'github-actions',
negatable: false,
help:
'Output errors in GitHub Actions annotation format (::error file=...::).',
)
..addOption(
'base-branch',
defaultsTo: 'origin/main',
help: 'Base branch to list files against',
)
..addFlag(
'help',
abbr: 'h',
Expand All @@ -56,6 +57,7 @@ void main(List<String> arguments) async {
}

final enforceDrafts = results.flag('enforce-drafts');
final validateGitHubUsers = results.flag('validate-github-users');
final githubActions = results.flag('github-actions');

final labels = <String>{
Expand All @@ -64,6 +66,7 @@ void main(List<String> arguments) async {
};

const fs = LocalFileSystem();
const gh = CliGitHubClient();

Taxonomy taxonomy;
try {
Expand All @@ -74,24 +77,26 @@ void main(List<String> arguments) async {
return;
}

final filesOnMain = await defaultGitList(
baseBranch: results.option('base-branch')!,
);
final filesOnMain = await defaultGitList(baseBranch: 'origin/main');

final linter = RfcLinter(
fs: fs,
gh: gh,
taxonomy: taxonomy,
labels: labels,
validateGitHubUsers: validateGitHubUsers,
existingFilesOnMain: filesOnMain,
enforceDrafts: enforceDrafts,
);

final issues = <LintIssue>[
if (results.rest.isNotEmpty)
for (final path in results.rest) ...await linter.lintFile(fs.file(path))
else
...await linter.lintDirectory(fs.directory('rfc')),
];
final issues = <LintIssue>[];
if (results.rest.isNotEmpty) {
for (final path in results.rest) {
issues.addAll(await linter.lintFile(fs.file(path)));
}
} else {
issues.addAll(await linter.lintDirectory(fs.directory('rfc')));
}

if (issues.isNotEmpty) {
stderr.writeln('RFC Lint failed with ${issues.length} issue(s):\n');
Expand Down
85 changes: 85 additions & 0 deletions bin/validate_rfc_number.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// 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',
help: 'If provided, validate against the base git branch for collisions',
)
..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 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user runs dart run bin/validate_rfc_number.dart --base-branch upstream/main won't the --base-branch flag be silently ignored because checkMain defaults to false?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, and I think I can use args to check that it was actually passed in by a user. Instead of two flags, I'll add one (base-branch) and if its provided, we'll check against it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(note to self, make sure the action is updated)

noDrafts: noDrafts,
checkBase: results.wasParsed('base-branch'),
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.',
);
}

void printHelp(ArgParser parser) {
stdout.writeln('RFC Semantic Validator - Flutter RFC Repository Tooling\n');
stdout.writeln(parser.usage);
}
10 changes: 3 additions & 7 deletions lib/src/git_lister.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@

import 'dart:io';

/// Signature for running an external process asynchronously.
typedef ProcessRunner =
Future<ProcessResult> Function(String executable, List<String> arguments);
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 @@ -25,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';
}
}
57 changes: 57 additions & 0 deletions lib/src/github_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 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';

/// Signature for running an external process asynchronously.
typedef ProcessRunner =
Future<ProcessResult> Function(String executable, List<String> arguments);

/// Abstract client for interacting with the GitHub API / CLI.
abstract interface class GitHubClient {
/// Checks whether a GitHub user exists.
Future<bool> userExists(String username);
}

/// Production implementation using the `gh` CLI.
class CliGitHubClient implements GitHubClient {
/// The process runner used to execute external commands.
final ProcessRunner processRunner;

const CliGitHubClient({this.processRunner = Process.run});

@override
Future<bool> userExists(String username) async {
try {
final result = await processRunner('gh', [
'api',
'users/$username',
'--silent',
]);
if (result.exitCode != 0) {
stdout.writeln('exit code: ${result.exitCode}');
stdout.writeln('gh api stdout:');
stdout.writeln(result.stdout);
stderr.writeln('gh api stderr:');
stderr.writeln(result.stderr);
}
return result.exitCode == 0;
} catch (_) {
return false;
}
}
}

/// Test double with in-memory state for hermetic unit testing.
class FakeGitHubClient implements GitHubClient {
final Set<String> existingUsers;

FakeGitHubClient({Set<String>? existingUsers})
: existingUsers = existingUsers ?? <String>{};

@override
Future<bool> userExists(String username) async {
return existingUsers.contains(username);
}
}
Loading
Loading