diff --git a/bin/rfc_lint.dart b/bin/rfc_lint.dart new file mode 100644 index 0000000..04dd078 --- /dev/null +++ b/bin/rfc_lint.dart @@ -0,0 +1,115 @@ +// 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/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'; + +void main(List arguments) async { + final parser = ArgParser() + ..addMultiOption( + 'labels', + help: 'Comma-separated list of GitHub Pull Request labels.', + ) + ..addFlag( + 'enforce-drafts', + negatable: false, + 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=...::).', + ) + ..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 Linter - Flutter RFC Repository Tooling\n'); + stdout.writeln(parser.usage); + return; + } + + final enforceDrafts = results.flag('enforce-drafts'); + final validateGitHubUsers = results.flag('validate-github-users'); + final githubActions = results.flag('github-actions'); + + final labels = { + for (var label in results.multiOption('labels')) + if (label.trim() case final trimmed when trimmed.isNotEmpty) trimmed, + }; + + const fs = LocalFileSystem(); + const gh = CliGitHubClient(); + + Taxonomy taxonomy; + try { + taxonomy = await Taxonomy.load(fs); + } catch (e) { + stderr.writeln('Failed to load taxonomy: $e'); + exitCode = 1; + return; + } + + 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 = []; + 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'); + for (final issue in issues) { + if (githubActions) { + stderr.writeln(issue.toGithubAnnotation()); + } else { + stderr.writeln('[ERROR] $issue'); + } + } + exitCode = 1; + return; + } + + stdout.writeln('All RFC documents passed lint checks cleanly.'); +} diff --git a/lib/src/linter.dart b/lib/src/linter.dart new file mode 100644 index 0000000..a9d6fa2 --- /dev/null +++ b/lib/src/linter.dart @@ -0,0 +1,262 @@ +// 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'; +import 'package:path/path.dart' as p; + +import 'github_client.dart'; +import 'models/rfc_file.dart'; +import 'taxonomy.dart'; + +/// A lint issue discovered in an RFC document. +class LintIssue { + final String filePath; + final int line; + final int column; + final String message; + + const LintIssue({ + required this.filePath, + required this.message, + this.line = 1, + this.column = 1, + }); + + /// Formats the issue as a GitHub Actions workflow annotation. + /// + /// Percent-encodes special characters (%, \r, \n) per GitHub Actions workflow + /// command specifications so multiline schema templates are preserved cleanly. + String toGithubAnnotation() { + final encoded = message + .replaceAll('%', '%25') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A'); + return '::error file=$filePath,line=$line,col=$column::$encoded'; + } + + @override + String toString() => '$filePath:$line:$column: $message'; +} + +/// Linter enforcing RFC structure, metadata, taxonomy, and number allocation rules. +class RfcLinter { + final FileSystem fs; + final GitHubClient gh; + final Taxonomy taxonomy; + final Set labels; + final bool validateGitHubUsers; + final Set existingFilesOnMain; + final bool enforceDrafts; + + RfcLinter({ + required this.fs, + required this.gh, + required this.taxonomy, + this.labels = const {}, + this.validateGitHubUsers = false, + this.existingFilesOnMain = const {}, + this.enforceDrafts = false, + }); + + /// Lints a single RFC file. + Future> lintFile(File file) async { + final issues = []; + final relativePath = file.path; + + if (!await file.exists()) { + issues.add(LintIssue(filePath: relativePath, message: 'File not found.')); + return issues; + } + + final content = await file.readAsString(); + final rfc = RfcFile.parse(content, path: file.path); + final fileName = p.basename(file.path); + + // 1. Filename & Path Validation + if (!rfc.hasValidFilename) { + issues.add( + LintIssue( + filePath: relativePath, + line: 1, + message: + 'Filename "$fileName" does not match required format "AAA.NNNN-.md" ' + '(where AAA is 3 digits, NNNN is 4 digits, and slug is lowercase kebab-case).', + ), + ); + return issues; // Cannot perform further structural checks reliably + } + + // 2. Taxonomy Validation + if (!taxonomy.isValidCategory(rfc.category!)) { + issues.add( + LintIssue( + filePath: relativePath, + line: 1, + message: + 'Subsystem category "${rfc.category}" is not defined in the architecture taxonomy. ' + 'See rfc/000.0001-flutter-architecture-and-reference-taxonomy.md.', + ), + ); + } + + // 3. Draft vs Assigned Number Enforcement (PR Context) + if (enforceDrafts) { + final existingBasenames = existingFilesOnMain.map(p.basename).toSet(); + final isExistingOnMain = existingBasenames.contains(fileName); + const bootstrapRfcs = {'000.0001', '000.0002'}; + final isBootstrap = bootstrapRfcs.contains(rfc.rfcId); + + if (!rfc.isDraft && !isExistingOnMain && !isBootstrap) { + final hasReadyOrAssigned = + labels.contains('rfc-ready') || labels.contains('rfc-assigned'); + if (!hasReadyOrAssigned) { + issues.add( + LintIssue( + filePath: relativePath, + line: 1, + message: + 'RFC has assigned number "${rfc.rfcId}", but PR does not have ' + '"rfc-ready" or "rfc-assigned" label. RFCs under review must use index "0000".', + ), + ); + } + } + } + + // 4. YAML Frontmatter Validation + if (!rfc.hasFrontmatter) { + issues.add( + LintIssue( + filePath: relativePath, + line: 1, + message: + '${rfc.frontmatterError ?? 'Missing YAML frontmatter block.'}\n\n' + 'Expected frontmatter format:\n${RfcFrontmatter.expectedSchemaTemplate.trimRight()}', + ), + ); + return issues; + } + + if (rfc.frontmatterError != null) { + issues.add( + LintIssue( + filePath: relativePath, + line: 1, + message: + '${rfc.frontmatterError!}\n\n' + 'Expected frontmatter format:\n${RfcFrontmatter.expectedSchemaTemplate.trimRight()}', + ), + ); + return issues; + } + + if (rfc.frontmatterErrors.isNotEmpty) { + for (final err in rfc.frontmatterErrors) { + issues.add(LintIssue(filePath: relativePath, line: 2, message: err)); + } + issues.add( + LintIssue( + filePath: relativePath, + line: 2, + message: + 'Expected frontmatter format:\n${RfcFrontmatter.expectedSchemaTemplate.trimRight()}', + ), + ); + } + + final fm = rfc.frontmatter; + final rfcId = fm?.rfc; + final expectedId = rfc.rfcId; + + if (rfcId != null && rfcId != expectedId) { + issues.add( + LintIssue( + filePath: relativePath, + line: 2, + message: + 'Frontmatter "rfc" value ("$rfcId") does not match filename identifier ("$expectedId").', + ), + ); + } + + // GitHub author existence verification (if enabled) + if (validateGitHubUsers && fm != null) { + for (final author in fm.authors) { + if (author is GitHubAuthor) { + final exists = await gh.userExists(author.username); + if (!exists) { + issues.add( + LintIssue( + filePath: relativePath, + line: 2, + message: 'GitHub user "${author.username}" does not exist.', + ), + ); + } + } + } + } + + // 5. First Heading Validation + if (rfc.firstHeading == null) { + issues.add( + LintIssue( + filePath: relativePath, + line: 1, + message: + 'Document must contain a top-level heading matching "# RFC ${rfc.rfcId}: ".', + ), + ); + } else { + if (rfc.firstHeadingId != expectedId) { + issues.add( + LintIssue( + filePath: relativePath, + line: rfc.firstHeadingLine ?? 1, + message: + 'First heading RFC identifier ("${rfc.firstHeadingId}") does not match "$expectedId".', + ), + ); + } + + final fmTitle = fm?.title.trim(); + if (fmTitle != null && + fmTitle.isNotEmpty && + rfc.firstHeadingTitle != fmTitle) { + issues.add( + LintIssue( + filePath: relativePath, + line: rfc.firstHeadingLine ?? 1, + message: + 'First heading title ("${rfc.firstHeadingTitle}") does not match frontmatter title ("$fmTitle").', + ), + ); + } + } + + return issues; + } + + /// Lints all RFC markdown files in the specified directory. + Future<List<LintIssue>> lintDirectory(Directory dir) async { + final issues = <LintIssue>[]; + if (!await dir.exists()) { + issues.add( + LintIssue(filePath: dir.path, message: 'Directory does not exist.'), + ); + return issues; + } + + final entries = await dir.list().toList(); + entries.sort((a, b) => a.path.compareTo(b.path)); + + for (final entry in entries) { + if (entry is File && entry.path.endsWith('.md')) { + issues.addAll(await lintFile(entry)); + } + } + + return issues; + } +} diff --git a/test/rfc_lint_test.dart b/test/rfc_lint_test.dart new file mode 100644 index 0000000..424b962 --- /dev/null +++ b/test/rfc_lint_test.dart @@ -0,0 +1,411 @@ +// 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/github_client.dart'; +import 'package:rfc_tools/src/linter.dart'; +import 'package:rfc_tools/src/taxonomy.dart'; +import 'package:test/test.dart'; + +void main() { + group('RfcLinter', () { + late MemoryFileSystem fs; + late FakeGitHubClient gh; + late Taxonomy taxonomy; + + const validTaxonomy = ''' +# RFC 000.0001: Taxonomy +### 000 – Meta +* **000:** Meta +### 100 – Core +* **110:** Foundation +'''; + + const validDoc = '''--- +type: rfc +rfc: '110.0000' +title: Sample Feature +description: A great new feature for foundation. +status: draft +created: 2026-09-01T00:00:00Z +updated: 2026-09-01T00:00:00Z +tags: + - 110-foundation +authors: + - https://github.com/octocat +--- + +# RFC 110.0000: Sample Feature + +## Overview +Body content here. +'''; + + setUp(() async { + fs = MemoryFileSystem(); + gh = FakeGitHubClient(existingUsers: {'octocat'}); + taxonomy = Taxonomy.fromMarkdown(validTaxonomy); + await fs.directory('rfc').create(recursive: true); + }); + + test('passes completely valid draft RFC in PR', () async { + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(validDoc); + + final linter = RfcLinter( + fs: fs, + gh: gh, + taxonomy: taxonomy, + labels: <String>{}, // PR without any special labels + validateGitHubUsers: true, + ); + + final issues = await linter.lintFile(file); + expect(issues, isEmpty); + }); + + test('detects non-kebab-case slug', () async { + final file = fs.file('rfc/110.0000-Invalid_Slug.md'); + await file.writeAsString(validDoc); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + + final issues = await linter.lintFile(file); + expect(issues, isNotEmpty); + expect(issues.first.message, contains('does not match required format')); + }); + + test('detects unknown category against taxonomy', () async { + final doc = validDoc + .replaceAll('110.0000', '999.0000') + .replaceAll('110-foundation', '999-unknown'); + final file = fs.file('rfc/999.0000-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => i.message.contains('not defined in the architecture taxonomy'), + ), + isTrue, + ); + }); + + test( + 'enforces .0000 when rfc-ready/rfc-assigned is absent in PR', + () async { + final doc = validDoc.replaceAll('110.0000', '110.0042'); + final file = fs.file('rfc/110.0042-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter( + fs: fs, + gh: gh, + taxonomy: taxonomy, + labels: <String>{}, // Missing rfc-ready and rfc-assigned + enforceDrafts: true, + ); + + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => + i.message.contains('RFCs under review must use index "0000"'), + ), + isTrue, + ); + }, + ); + + test('allows .NNNN when rfc-assigned or rfc-ready is present', () async { + final doc = validDoc.replaceAll('110.0000', '110.0042'); + final file = fs.file('rfc/110.0042-sample-feature.md'); + await file.writeAsString(doc); + + final linterReady = RfcLinter( + fs: fs, + gh: gh, + taxonomy: taxonomy, + labels: {'rfc-ready'}, + enforceDrafts: true, + ); + expect(await linterReady.lintFile(file), isEmpty); + + final linterAssigned = RfcLinter( + fs: fs, + gh: gh, + taxonomy: taxonomy, + labels: {'rfc-assigned'}, + enforceDrafts: true, + ); + expect(await linterAssigned.lintFile(file), isEmpty); + }); + + test( + 'allows .NNNN when enforceDrafts is false (standard mode or main)', + () async { + final doc = validDoc.replaceAll('110.0000', '110.0042'); + final file = fs.file('rfc/110.0042-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + + expect(await linter.lintFile(file), isEmpty); + }, + ); + + test('validates GitHub username existence via fake client', () async { + final doc = validDoc.replaceAll('octocat', 'nonexistent-user'); + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter( + fs: fs, + gh: gh, + taxonomy: taxonomy, + validateGitHubUsers: true, + ); + + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => i.message.contains( + 'GitHub user "nonexistent-user" does not exist', + ), + ), + isTrue, + ); + }); + + test('detects missing required frontmatter fields', () async { + const missingType = '''--- +rfc: '110.0000' +title: Test +description: Test +status: draft +created: 2026-09-01T00:00:00Z +updated: 2026-09-01T00:00:00Z +tags: [110-foundation] +authors: [https://github.com/octocat] +--- +# RFC 110.0000: Test +'''; + final file = fs.file('rfc/110.0000-test.md'); + await file.writeAsString(missingType); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => i.message.contains('Frontmatter "type" must be "rfc"'), + ), + isTrue, + ); + expect( + issues.any((i) => i.message.contains('Expected frontmatter format:')), + isTrue, + ); + }); + + test( + 'reports multiple frontmatter errors together along with expected schema template', + () async { + const missingAuthorAndUpdated = '''--- +type: rfc +rfc: '110.0000' +title: Multiple Errors +description: Missing author and updated timestamp. +status: draft +created: 2026-09-01T00:00:00Z +tags: [110-foundation] +--- +# RFC 110.0000: Multiple Errors +'''; + final file = fs.file('rfc/110.0000-multiple-errors.md'); + await file.writeAsString(missingAuthorAndUpdated); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + + expect( + issues.any( + (i) => i.message.contains( + 'Frontmatter "updated" must be an ISO 8601 UTC timestamp.', + ), + ), + isTrue, + ); + expect( + issues.any( + (i) => i.message.contains( + 'Frontmatter "authors" must be a non-empty list of authors.', + ), + ), + isTrue, + ); + expect( + issues.any((i) => i.message.contains('Expected frontmatter format:')), + isTrue, + ); + }, + ); + + test('detects heading title mismatch', () async { + const headingMismatch = '''--- +type: rfc +rfc: '110.0000' +title: Real Title +description: Test +status: draft +created: 2026-09-01T00:00:00Z +updated: 2026-09-01T00:00:00Z +tags: [110-foundation] +authors: [https://github.com/octocat] +--- +# RFC 110.0000: Mismatched Title +'''; + final file = fs.file('rfc/110.0000-test.md'); + await file.writeAsString(headingMismatch); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any((i) => i.message.contains('First heading title')), + isTrue, + ); + }); + + test('detects empty items in frontmatter tags', () async { + final doc = validDoc.replaceAll( + 'tags:\n - 110-foundation', + 'tags: [""]', + ); + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => i.message.contains( + 'Frontmatter "tags" items must be non-empty strings', + ), + ), + isTrue, + ); + }); + + test('detects frontmatter rfc mismatch with filename identifier', () async { + final doc = validDoc.replaceAll("rfc: '110.0000'", "rfc: '110.0001'"); + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => i.message.contains( + 'Frontmatter "rfc" value ("110.0001") does not match filename identifier ("110.0000").', + ), + ), + isTrue, + ); + }); + + test('detects unclosed frontmatter', () async { + const unclosed = '''--- +type: rfc +rfc: '110.0000' +title: Unclosed +# Missing closing delimiter +'''; + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(unclosed); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => i.message.contains('Unclosed YAML frontmatter delimiter'), + ), + isTrue, + ); + }); + + test('detects invalid non-UTC timestamp', () async { + final doc = validDoc.replaceAll( + 'created: 2026-09-01T00:00:00Z', + 'created: 2026-09-01 12:00:00', + ); + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter(fs: fs, gh: gh, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => i.message.contains('must be an ISO 8601 UTC timestamp'), + ), + isTrue, + ); + }); + + test( + 'allows editing an existing RFC from main without PR labels', + () async { + final doc = validDoc.replaceAll('110.0000', '110.0001'); + final file = fs.file('rfc/110.0001-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter( + fs: fs, + gh: gh, + taxonomy: taxonomy, + labels: <String>{}, // PR with no labels + existingFilesOnMain: { + 'rfc/110.0001-sample-feature.md', + }, // Already merged on main! + ); + + final issues = await linter.lintFile(file); + expect(issues, isEmpty); + }, + ); + + group('LintIssue', () { + test( + 'toGithubAnnotation percent-encodes newlines and special characters', + () { + const template = ''' +Expected frontmatter format: +type: rfc +rfc: '000.0001' +description: 100% complete +'''; + const issue = LintIssue( + filePath: 'rfc/110.0000-feature.md', + line: 2, + column: 1, + message: template, + ); + + final annotation = issue.toGithubAnnotation(); + expect(annotation.contains('\n'), isFalse); + expect(annotation.contains('\r'), isFalse); + expect( + annotation, + startsWith('::error file=rfc/110.0000-feature.md,line=2,col=1::'), + ); + expect( + annotation, + contains('Expected frontmatter format:%0Atype: rfc'), + ); + expect(annotation, contains('100%25 complete')); + }, + ); + }); + }); +}