From f3ce84a7fce9ed44ddb2b808a611c818ac34c3fa Mon Sep 17 00:00:00 2001 From: John McDole Date: Thu, 3 Sep 2026 16:08:07 -0700 Subject: [PATCH 1/3] feat(rfc_tools): add taxonomy parser, github client, and git lister --- lib/src/git_lister.dart | 40 +++++++++++++++++++ lib/src/github_client.dart | 50 ++++++++++++++++++++++++ lib/src/taxonomy.dart | 72 +++++++++++++++++++++++++++++++++++ test/git_lister_test.dart | 61 +++++++++++++++++++++++++++++ test/github_client_test.dart | 69 +++++++++++++++++++++++++++++++++ test/mock_process_runner.dart | 44 +++++++++++++++++++++ test/taxonomy_test.dart | 64 +++++++++++++++++++++++++++++++ 7 files changed, 400 insertions(+) create mode 100644 lib/src/git_lister.dart create mode 100644 lib/src/github_client.dart create mode 100644 lib/src/taxonomy.dart create mode 100644 test/git_lister_test.dart create mode 100644 test/github_client_test.dart create mode 100644 test/mock_process_runner.dart create mode 100644 test/taxonomy_test.dart diff --git a/lib/src/git_lister.dart b/lib/src/git_lister.dart new file mode 100644 index 0000000..1db7ccc --- /dev/null +++ b/lib/src/git_lister.dart @@ -0,0 +1,40 @@ +// Copyright 2026 The Flutter Authors. 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'; + +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 runProcess = Process.run, +}) async { + try { + final result = await runProcess('git', [ + 'ls-tree', + '-r', + '--name-only', + baseBranch, + '--', + '$rfcDir/', + ]); + if (result.exitCode != 0) { + 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..07f90d7 --- /dev/null +++ b/lib/src/github_client.dart @@ -0,0 +1,50 @@ +// Copyright 2026 The Flutter Authors. 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'; + +/// 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', + ]); + 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..9148ec0 --- /dev/null +++ b/lib/src/taxonomy.dart @@ -0,0 +1,72 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// 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..ae10d7d --- /dev/null +++ b/test/git_lister_test.dart @@ -0,0 +1,61 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// 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', + runProcess: 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', + runProcess: 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', + runProcess: 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..3698971 --- /dev/null +++ b/test/github_client_test.dart @@ -0,0 +1,69 @@ +// Copyright 2026 The Flutter Authors. 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'; +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..7a86177 --- /dev/null +++ b/test/mock_process_runner.dart @@ -0,0 +1,44 @@ +// Copyright 2026 The Flutter Authors. 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'; + +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..15d3f60 --- /dev/null +++ b/test/taxonomy_test.dart @@ -0,0 +1,64 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// 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); + }); + }); +} From 305d000a09dae5b13c8523d03b9c3addc01671b8 Mon Sep 17 00:00:00 2001 From: John McDole Date: Fri, 4 Sep 2026 12:31:25 -0700 Subject: [PATCH 2/3] your rights have been unreserved --- lib/src/git_lister.dart | 2 +- lib/src/github_client.dart | 2 +- lib/src/taxonomy.dart | 2 +- test/git_lister_test.dart | 2 +- test/github_client_test.dart | 2 +- test/mock_process_runner.dart | 2 +- test/taxonomy_test.dart | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/src/git_lister.dart b/lib/src/git_lister.dart index 1db7ccc..cd6b930 100644 --- a/lib/src/git_lister.dart +++ b/lib/src/git_lister.dart @@ -1,4 +1,4 @@ -// Copyright 2026 The Flutter Authors. All rights reserved. +// 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. diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart index 07f90d7..909f522 100644 --- a/lib/src/github_client.dart +++ b/lib/src/github_client.dart @@ -1,4 +1,4 @@ -// Copyright 2026 The Flutter Authors. All rights reserved. +// 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. diff --git a/lib/src/taxonomy.dart b/lib/src/taxonomy.dart index 9148ec0..92963b0 100644 --- a/lib/src/taxonomy.dart +++ b/lib/src/taxonomy.dart @@ -1,4 +1,4 @@ -// Copyright 2026 The Flutter Authors. All rights reserved. +// 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. diff --git a/test/git_lister_test.dart b/test/git_lister_test.dart index ae10d7d..a57d3d6 100644 --- a/test/git_lister_test.dart +++ b/test/git_lister_test.dart @@ -1,4 +1,4 @@ -// Copyright 2026 The Flutter Authors. All rights reserved. +// 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. diff --git a/test/github_client_test.dart b/test/github_client_test.dart index 3698971..aad2b04 100644 --- a/test/github_client_test.dart +++ b/test/github_client_test.dart @@ -1,4 +1,4 @@ -// Copyright 2026 The Flutter Authors. All rights reserved. +// 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. diff --git a/test/mock_process_runner.dart b/test/mock_process_runner.dart index 7a86177..132b52d 100644 --- a/test/mock_process_runner.dart +++ b/test/mock_process_runner.dart @@ -1,4 +1,4 @@ -// Copyright 2026 The Flutter Authors. All rights reserved. +// 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. diff --git a/test/taxonomy_test.dart b/test/taxonomy_test.dart index 15d3f60..fdff9b5 100644 --- a/test/taxonomy_test.dart +++ b/test/taxonomy_test.dart @@ -1,4 +1,4 @@ -// Copyright 2026 The Flutter Authors. All rights reserved. +// 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. From 968f918deea4fb27514f69dedc7d78833961ab2d Mon Sep 17 00:00:00 2001 From: John McDole Date: Fri, 4 Sep 2026 12:53:42 -0700 Subject: [PATCH 3/3] stdout+stderr+processRunner --- lib/src/git_lister.dart | 9 +++++++-- lib/src/github_client.dart | 7 +++++++ test/git_lister_test.dart | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/src/git_lister.dart b/lib/src/git_lister.dart index cd6b930..6b20cdc 100644 --- a/lib/src/git_lister.dart +++ b/lib/src/git_lister.dart @@ -14,10 +14,10 @@ typedef GitListFunction = Future> defaultGitList({ String baseBranch = 'origin/main', String rfcDir = 'rfc', - ProcessRunner runProcess = Process.run, + ProcessRunner processRunner = Process.run, }) async { try { - final result = await runProcess('git', [ + final result = await processRunner('git', [ 'ls-tree', '-r', '--name-only', @@ -26,6 +26,11 @@ Future> defaultGitList({ '$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; diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart index 909f522..182756f 100644 --- a/lib/src/github_client.dart +++ b/lib/src/github_client.dart @@ -29,6 +29,13 @@ class CliGitHubClient implements GitHubClient { '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; diff --git a/test/git_lister_test.dart b/test/git_lister_test.dart index a57d3d6..6bf5b33 100644 --- a/test/git_lister_test.dart +++ b/test/git_lister_test.dart @@ -17,7 +17,7 @@ void main() { final files = await defaultGitList( baseBranch: 'origin/main', - runProcess: runner.run, + processRunner: runner.run, ); expect( @@ -39,7 +39,7 @@ void main() { final files = await defaultGitList( baseBranch: 'origin/main', - runProcess: runner.run, + processRunner: runner.run, ); expect(files, isEmpty); @@ -52,7 +52,7 @@ void main() { final files = await defaultGitList( baseBranch: 'origin/main', - runProcess: runner.run, + processRunner: runner.run, ); expect(files, isEmpty);