diff --git a/lib/src/git_lister.dart b/lib/src/git_lister.dart new file mode 100644 index 0000000..6b20cdc --- /dev/null +++ b/lib/src/git_lister.dart @@ -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> Function({String baseBranch, String rfcDir}); + +/// Default implementation querying git via `git ls-tree`. +Future> 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 {}; + } + final stdoutStr = result.stdout as String; + return stdoutStr + .split('\n') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toSet(); + } catch (_) { + return const {}; + } +} diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart new file mode 100644 index 0000000..182756f --- /dev/null +++ b/lib/src/github_client.dart @@ -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 Function(String executable, List arguments); + +/// Abstract client for interacting with the GitHub API / CLI. +abstract interface class GitHubClient { + /// Checks whether a GitHub user exists. + Future 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 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 existingUsers; + + FakeGitHubClient({Set? existingUsers}) + : existingUsers = existingUsers ?? {}; + + @override + Future userExists(String username) async { + return existingUsers.contains(username); + } +} diff --git a/lib/src/taxonomy.dart b/lib/src/taxonomy.dart new file mode 100644 index 0000000..92963b0 --- /dev/null +++ b/lib/src/taxonomy.dart @@ -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 categories; + + /// Optional mapping from category string to human-readable title. + final Map 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 = {}; + final categoryNames = {}; + + // 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 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); + } +} diff --git a/test/git_lister_test.dart b/test/git_lister_test.dart new file mode 100644 index 0000000..6bf5b33 --- /dev/null +++ b/test/git_lister_test.dart @@ -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); + }); + }); +} diff --git a/test/github_client_test.dart b/test/github_client_test.dart new file mode 100644 index 0000000..aad2b04 --- /dev/null +++ b/test/github_client_test.dart @@ -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); + }); + }); +} diff --git a/test/mock_process_runner.dart b/test/mock_process_runner.dart new file mode 100644 index 0000000..132b52d --- /dev/null +++ b/test/mock_process_runner.dart @@ -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 Function(String executable, List 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 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 run(String executable, List 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 call(String executable, List arguments) => + run(executable, arguments); +} diff --git a/test/taxonomy_test.dart b/test/taxonomy_test.dart new file mode 100644 index 0000000..fdff9b5 --- /dev/null +++ b/test/taxonomy_test.dart @@ -0,0 +1,64 @@ +// 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/memory.dart'; +import 'package:rfc_tools/src/taxonomy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Taxonomy', () { + test('parses section headers and list items from markdown', () { + const sample = ''' +# RFC 000.0001: Architecture Taxonomy + +### 000 – General, Process, & Meta +Governance and how the Flutter project itself functions. + +* **000:** RFC Process & Templates +* **010:** Governance & Steering Committees + +### 100 – Flutter Framework Core +The Dart-side architecture of Flutter. + +* **110:** Foundation & Low-level +* **120:** Rendering Layer +'''; + + final taxonomy = Taxonomy.fromMarkdown(sample); + expect( + taxonomy.categories, + containsAll(['000', '010', '100', '110', '120']), + ); + expect(taxonomy.isValidCategory('000'), isTrue); + expect(taxonomy.isValidCategory('010'), isTrue); + expect(taxonomy.isValidCategory('110'), isTrue); + expect(taxonomy.isValidCategory('999'), isFalse); + expect(taxonomy.isValidCategory('abc'), isFalse); + }); + + test('loads successfully from FileSystem', () async { + final fs = MemoryFileSystem(); + final file = fs.file( + 'rfc/000.0001-flutter-architecture-and-reference-taxonomy.md', + ); + await file.create(recursive: true); + await file.writeAsString(''' +# RFC 000.0001: Taxonomy +### 100 – Core +* **110:** Foundation +'''); + + final taxonomy = await Taxonomy.load(fs); + expect(taxonomy.isValidCategory('110'), isTrue); + expect(taxonomy.isValidCategory('999'), isFalse); + }); + + test('throws StateError when taxonomy document does not exist', () async { + final fs = MemoryFileSystem(); + await fs.directory('rfc').create(recursive: true); + + expect(() => Taxonomy.load(fs), throwsStateError); + }); + }); +}