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

/// 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) {
stderr.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>{};
Comment thread
jtmcdole marked this conversation as resolved.
}
final stdoutStr = result.stdout as String;
return stdoutStr
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
} catch (_) {
return const <String>{};
}
}
70 changes: 70 additions & 0 deletions lib/src/taxonomy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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 {
const defaultPath =
'rfc/000.0001-flutter-architecture-and-reference-taxonomy.md';
final 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);
});
});
}
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);
}
64 changes: 64 additions & 0 deletions test/taxonomy_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading