diff --git a/bin/rfc_lint.dart b/bin/rfc_lint.dart new file mode 100644 index 0000000..441a232 --- /dev/null +++ b/bin/rfc_lint.dart @@ -0,0 +1,110 @@ +// 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/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( + 'github-actions', + negatable: false, + help: + 'Output errors in GitHub Actions annotation format (::error file=...::).', + ) + ..addOption( + 'base-branch', + defaultsTo: 'origin/main', + help: 'Base branch to list files against', + ) + ..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 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(); + + 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: results.option('base-branch')!, + ); + + final linter = RfcLinter( + fs: fs, + taxonomy: taxonomy, + labels: labels, + existingFilesOnMain: filesOnMain, + enforceDrafts: enforceDrafts, + ); + + final issues = [ + if (results.rest.isNotEmpty) + for (final path in results.rest) ...await linter.lintFile(fs.file(path)) + else + ...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..2e59ff9 --- /dev/null +++ b/lib/src/linter.dart @@ -0,0 +1,218 @@ +// 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 '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 Taxonomy taxonomy; + final Set labels; + final Set existingBasenames; + final bool enforceDrafts; + + RfcLinter({ + required this.fs, + required this.taxonomy, + this.labels = const {}, + this.enforceDrafts = false, + Set existingFilesOnMain = const {}, + }) : existingBasenames = { + for (final file in existingFilesOnMain) p.basename(file), + }; + + /// 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 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.frontmatterErrors.isNotEmpty) { + for (final (:line, :error) in rfc.frontmatterErrors) { + issues.add( + LintIssue(filePath: relativePath, line: line, message: error), + ); + } + issues.add( + LintIssue( + filePath: relativePath, + line: rfc.hasFrontmatter ? 2 : 1, + message: + 'Expected frontmatter format:\n${RfcFrontmatter.expectedSchemaTemplate.trimRight()}', + ), + ); + if (!rfc.hasFrontmatter) { + return issues; + } + } + + 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").', + ), + ); + } + + // 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..b3e2341 --- /dev/null +++ b/test/rfc_lint_test.dart @@ -0,0 +1,459 @@ +// 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/linter.dart'; +import 'package:rfc_tools/src/taxonomy.dart'; +import 'package:test/test.dart'; + +void main() { + group('RfcLinter', () { + late MemoryFileSystem fs; + 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(); + 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, + taxonomy: taxonomy, + labels: <String>{}, // PR without any special labels + ); + + 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, 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, 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, + 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, + taxonomy: taxonomy, + labels: {'rfc-ready'}, + enforceDrafts: true, + ); + expect(await linterReady.lintFile(file), isEmpty); + + final linterAssigned = RfcLinter( + fs: fs, + 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, taxonomy: taxonomy); + + expect(await linter.lintFile(file), isEmpty); + }, + ); + + 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, 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, 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, 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, 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, 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, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => + i.line == 1 && + i.message.contains('Unclosed YAML frontmatter delimiter') && + !i.message.contains('(error:'), + ), + isTrue, + ); + expect( + issues.any( + (i) => + i.line == 1 && i.message.contains('Expected frontmatter format:'), + ), + isTrue, + ); + }); + + test('detects missing frontmatter delimiter', () async { + const noFm = ''' +# RFC 110.0000: No Frontmatter + +Body content here. +'''; + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(noFm); + + final linter = RfcLinter(fs: fs, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => + i.line == 1 && + i.message.contains( + 'File does not start with YAML frontmatter delimiter `---`.', + ) && + !i.message.contains('(error:'), + ), + isTrue, + ); + expect( + issues.any( + (i) => + i.line == 1 && i.message.contains('Expected frontmatter format:'), + ), + isTrue, + ); + }); + + test( + 'reports exact line numbers for frontmatter field schema errors', + () async { + // Line 1: --- + // Line 2: type: rfc + // Line 3: rfc: '110.0000' + // Line 4: title: Sample Feature + // Line 5: description: A great new feature for foundation. + // Line 6: status: invalid_status + // Line 7: created: 2026-09-01T00:00:00Z + // Line 8: updated: 2026-09-01T00:00:00Z + // Line 9: tags: [110-foundation] + // Line 10: authors: [https://github.com/octocat] + // Line 11: --- + final doc = validDoc.replaceAll( + 'status: draft', + 'status: invalid_status', + ); + final file = fs.file('rfc/110.0000-sample-feature.md'); + await file.writeAsString(doc); + + final linter = RfcLinter(fs: fs, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + + final statusIssue = issues.firstWhere( + (i) => i.message.contains('Frontmatter "status" must be one of:'), + ); + expect(statusIssue.line, equals(6)); + + final templateIssue = issues.firstWhere( + (i) => i.message.contains('Expected frontmatter format:'), + ); + expect(templateIssue.line, equals(2)); + }, + ); + + 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, taxonomy: taxonomy); + final issues = await linter.lintFile(file); + expect( + issues.any( + (i) => + i.line == 7 && + 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, + + 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')); + }, + ); + }); + }); +}