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
45 changes: 45 additions & 0 deletions lib/src/git_lister.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 '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});

/// 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 {
final result = await processRunner('git', [
'ls-tree',
'-r',
'--name-only',
baseBranch,
'--',
'$rfcDir/',
]);
if (result.exitCode != 0) {
stdout.writeln('exit code: ${result.exitCode}');
stdout.writeln('git ls-tree stdout:');
stdout.writeln(result.stdout);
stderr.writeln('git ls-tree stderr:');
stderr.writeln(result.stderr);
return const <String>{};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It looks like stdout and stderr from git are getting dropped here. You might want to print them somewhere if the git command fails.

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.

I'm adding both stdout and stderr for here and the other commands. That way nothing is lost.

}
final stdoutStr = result.stdout as String;
return stdoutStr
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
} catch (_) {
return const <String>{};
}
}
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;
Comment thread
jtmcdole marked this conversation as resolved.
} 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);
}
}
72 changes: 72 additions & 0 deletions lib/src/taxonomy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// 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 'package:file/file.dart';

/// Represents the dynamic taxonomy of Flutter subsystems extracted from RFC 000.0001.
class Taxonomy {
/// Set of all valid 3-digit category strings (e.g. "000", "010", "110", "210").
final Set<String> categories;

/// Optional mapping from category string to human-readable title.
final Map<String, String> categoryNames;

const Taxonomy(this.categories, [this.categoryNames = const {}]);

/// Checks whether [category] is a recognized subsystem classification.
bool isValidCategory(String category) => categories.contains(category);

/// Parses taxonomy categories from markdown content of RFC 000.0001.
static Taxonomy fromMarkdown(String markdown) {
final categories = <String>{};
final categoryNames = <String, String>{};

// Match section headers: ### 000 – General, Process, & Meta
final sectionHeaderRegex = RegExp(
r'^###\s+([0-9]{3})\s+[–—-]\s*(.*)$',
multiLine: true,
);
for (final match in sectionHeaderRegex.allMatches(markdown)) {
final code = match.group(1)!;
final name = match.group(2)?.trim() ?? '';
categories.add(code);
categoryNames[code] = name;
}

// Match list items: * **110:** Foundation & Low-level
final listItemRegex = RegExp(
r'^\*\s+\*\*([0-9]{3}):?\*\*:?\s*(.*)$',
multiLine: true,
);
for (final match in listItemRegex.allMatches(markdown)) {
final code = match.group(1)!;
final name = match.group(2)?.trim() ?? '';
categories.add(code);
if (name.isNotEmpty) {
categoryNames[code] = name;
}
}

return Taxonomy(categories, categoryNames);
}

/// Loads the taxonomy from RFC 000.0001 on the given [fs].
static Future<Taxonomy> load(FileSystem fs) async {
File? file;

const defaultPath =
'rfc/000.0001-flutter-architecture-and-reference-taxonomy.md';
file = fs.file(defaultPath);

if (!await file.exists()) {
throw StateError(
'Could not locate RFC 000.0001 taxonomy document in "rfc". '
'Ensure rfc/000.0001-flutter-architecture-and-reference-taxonomy.md exists.',
);
}

final content = await file.readAsString();
return Taxonomy.fromMarkdown(content);
}
}
61 changes: 61 additions & 0 deletions test/git_lister_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// 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 'package:rfc_tools/src/git_lister.dart';
import 'package:test/test.dart';

import 'mock_process_runner.dart';

void main() {
group('defaultGitList', () {
test('returns parsed file set on zero exit code', () async {
final runner = MockProcessRunner(
exitCode: 0,
stdout: 'rfc/000.0001-taxonomy.md\nrfc/000.0002-process.md\n',
);

final files = await defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
);

expect(
files,
equals({'rfc/000.0001-taxonomy.md', 'rfc/000.0002-process.md'}),
);
expect(runner.calls, hasLength(1));
expect(
runner.calls.first.arguments,
equals(['ls-tree', '-r', '--name-only', 'origin/main', '--', 'rfc/']),
);
});

test('returns empty set on non-zero exit code', () async {
final runner = MockProcessRunner(
exitCode: 128,
stderr: 'fatal: not a valid object name',
);

final files = await defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
);

expect(files, isEmpty);
});

test('returns empty set when process throws', () async {
final runner = MockProcessRunner(
exceptionToThrow: Exception('process failed'),
);

final files = await defaultGitList(
baseBranch: 'origin/main',
processRunner: runner.run,
);

expect(files, isEmpty);
});
});
}
69 changes: 69 additions & 0 deletions test/github_client_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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:rfc_tools/src/github_client.dart';
import 'package:test/test.dart';
import 'mock_process_runner.dart';

void main() {
group('CliGitHubClient', () {
test('default constructor uses Process.run', () {
const client = CliGitHubClient();
expect(client.processRunner, equals(Process.run));
});

group('userExists', () {
test('returns true when exitCode is 0', () async {
final runner = MockProcessRunner(exitCode: 0);
final client = CliGitHubClient(processRunner: runner.run);

final exists = await client.userExists('octocat');

expect(exists, isTrue);
expect(runner.calls, hasLength(1));
expect(runner.calls.first.executable, equals('gh'));
expect(
runner.calls.first.arguments,
equals(['api', 'users/octocat', '--silent']),
);
});

test('returns false when exitCode is non-zero', () async {
final runner = MockProcessRunner(exitCode: 1);
final client = CliGitHubClient(processRunner: runner.run);

final exists = await client.userExists('unknown-user');

expect(exists, isFalse);
expect(runner.calls, hasLength(1));
expect(runner.calls.first.executable, equals('gh'));
expect(
runner.calls.first.arguments,
equals(['api', 'users/unknown-user', '--silent']),
);
});

test('returns false when process runner throws', () async {
final runner = MockProcessRunner(
exceptionToThrow: const SocketException('network down'),
);
final client = CliGitHubClient(processRunner: runner.run);

final exists = await client.userExists('octocat');

expect(exists, isFalse);
});
});
});

group('FakeGitHubClient', () {
test('verifies user existence against existingUsers set', () async {
final client = FakeGitHubClient(existingUsers: {'user1'});

expect(await client.userExists('user1'), isTrue);
expect(await client.userExists('unknown'), isFalse);
});
});
}
44 changes: 44 additions & 0 deletions test/mock_process_runner.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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';

typedef MockProcessHandler =
Future<ProcessResult> Function(String executable, List<String> arguments);

/// In-memory mock process runner to record external process invocations and
/// control process outputs hermetically in tests.
class MockProcessRunner {
final List<({String executable, List<String> arguments})> calls = [];
MockProcessHandler? handler;
int exitCode;
dynamic stdout;
dynamic stderr;
Object? exceptionToThrow;

MockProcessRunner({
this.exitCode = 0,
this.stdout = '',
this.stderr = '',
this.exceptionToThrow,
this.handler,
});

Future<ProcessResult> run(String executable, List<String> arguments) async {
calls.add((
executable: executable,
arguments: List.unmodifiable(arguments),
));
if (exceptionToThrow != null) {
throw exceptionToThrow!;
}
if (handler != null) {
return await handler!(executable, arguments);
}
return ProcessResult(1234, exitCode, stdout, stderr);
}

Future<ProcessResult> call(String executable, List<String> arguments) =>
run(executable, arguments);
}
Loading
Loading