-
Notifications
You must be signed in to change notification settings - Fork 2.8k
/
readme_check_command.dart
328 lines (288 loc) · 12.5 KB
/
readme_check_command.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
// Copyright 2013 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';
import 'package:yaml/yaml.dart';
import 'common/core.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/repository_package.dart';
const String _instructionUrl =
'https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md';
/// A command to enforce README conventions across the repository.
class ReadmeCheckCommand extends PackageLoopingCommand {
/// Creates an instance of the README check command.
ReadmeCheckCommand(
super.packagesDir, {
super.processRunner,
super.platform,
super.gitDir,
}) {
argParser.addFlag(_requireExcerptsArg,
help: 'Require that Dart code blocks be managed by code-excerpt.');
}
static const String _requireExcerptsArg = 'require-excerpts';
// Standardized capitalizations for platforms that a plugin can support.
static const Map<String, String> _standardPlatformNames = <String, String>{
'android': 'Android',
'ios': 'iOS',
'linux': 'Linux',
'macos': 'macOS',
'web': 'Web',
'windows': 'Windows',
};
@override
final String name = 'readme-check';
@override
List<String> get aliases => <String>['check-readme'];
@override
final String description =
'Checks that READMEs follow repository conventions.';
@override
bool get hasLongOutput => false;
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
final List<String> errors = _validateReadme(package.readmeFile,
mainPackage: package, isExample: false);
for (final RepositoryPackage packageToCheck in package.getExamples()) {
errors.addAll(_validateReadme(packageToCheck.readmeFile,
mainPackage: package, isExample: true));
}
// If there's an example/README.md for a multi-example package, validate
// that as well, as it will be shown on pub.dev.
final Directory exampleDir = package.directory.childDirectory('example');
final File exampleDirReadme = exampleDir.childFile('README.md');
if (exampleDir.existsSync() && !isPackage(exampleDir)) {
errors.addAll(_validateReadme(exampleDirReadme,
mainPackage: package, isExample: true));
}
return errors.isEmpty
? PackageResult.success()
: PackageResult.fail(errors);
}
List<String> _validateReadme(File readme,
{required RepositoryPackage mainPackage, required bool isExample}) {
if (!readme.existsSync()) {
if (isExample) {
print('${indentation}No README for '
'${getRelativePosixPath(readme.parent, from: mainPackage.directory)}');
return <String>[];
} else {
printError('${indentation}No README found at '
'${getRelativePosixPath(readme, from: mainPackage.directory)}');
return <String>['Missing README.md'];
}
}
print('${indentation}Checking '
'${getRelativePosixPath(readme, from: mainPackage.directory)}...');
final List<String> readmeLines = readme.readAsLinesSync();
final List<String> errors = <String>[];
final String? blockValidationError =
_validateCodeBlocks(readmeLines, mainPackage: mainPackage);
if (blockValidationError != null) {
errors.add(blockValidationError);
}
errors.addAll(_validateBoilerplate(readmeLines,
mainPackage: mainPackage, isExample: isExample));
// Check if this is the main readme for a plugin, and if so enforce extra
// checks.
if (!isExample) {
final Pubspec pubspec = mainPackage.parsePubspec();
final bool isPlugin = pubspec.flutter?['plugin'] != null;
if (isPlugin && (!mainPackage.isFederated || mainPackage.isAppFacing)) {
final String? error = _validateSupportedPlatforms(readmeLines, pubspec);
if (error != null) {
errors.add(error);
}
}
}
return errors;
}
/// Validates that code blocks (``` ... ```) follow repository standards.
String? _validateCodeBlocks(
List<String> readmeLines, {
required RepositoryPackage mainPackage,
}) {
final RegExp codeBlockDelimiterPattern = RegExp(r'^\s*```\s*([^ ]*)\s*');
const String excerptTagStart = '<?code-excerpt ';
final List<int> missingLanguageLines = <int>[];
final List<int> missingExcerptLines = <int>[];
bool inBlock = false;
for (int i = 0; i < readmeLines.length; ++i) {
final RegExpMatch? match =
codeBlockDelimiterPattern.firstMatch(readmeLines[i]);
if (match == null) {
continue;
}
if (inBlock) {
inBlock = false;
continue;
}
inBlock = true;
final int humanReadableLineNumber = i + 1;
// Ensure that there's a language tag.
final String infoString = match[1] ?? '';
if (infoString.isEmpty) {
missingLanguageLines.add(humanReadableLineNumber);
continue;
}
// Check for code-excerpt usage if requested.
if (getBoolArg(_requireExcerptsArg) && infoString == 'dart') {
if (i == 0 || !readmeLines[i - 1].trim().startsWith(excerptTagStart)) {
missingExcerptLines.add(humanReadableLineNumber);
}
}
}
String? errorSummary;
if (missingLanguageLines.isNotEmpty) {
for (final int lineNumber in missingLanguageLines) {
printError('${indentation}Code block at line $lineNumber is missing '
'a language identifier.');
}
printError(
'\n${indentation}For each block listed above, add a language tag to '
'the opening block. For instance, for Dart code, use:\n'
'${indentation * 2}```dart\n');
errorSummary = 'Missing language identifier for code block';
}
if (missingExcerptLines.isNotEmpty) {
for (final int lineNumber in missingExcerptLines) {
printError('${indentation}Dart code block at line $lineNumber is not '
'managed by code-excerpt.');
}
printError(
'\n${indentation}For each block listed above, add <?code-excerpt ...> '
'tag on the previous line, as explained at\n'
'$_instructionUrl');
errorSummary ??= 'Missing code-excerpt management for code block';
}
return errorSummary;
}
/// Validates that the plugin has a supported platforms table following the
/// expected format, returning an error string if any issues are found.
String? _validateSupportedPlatforms(
List<String> readmeLines, Pubspec pubspec) {
// Example table following expected format:
// | | Android | iOS | Web |
// |----------------|---------|----------|------------------------|
// | **Support** | SDK 21+ | iOS 10+* | [See `camera_web `][1] |
final int detailsLineNumber = readmeLines
.indexWhere((String line) => line.startsWith('| **Support**'));
if (detailsLineNumber == -1) {
return 'No OS support table found';
}
final int osLineNumber = detailsLineNumber - 2;
if (osLineNumber < 0 || !readmeLines[osLineNumber].startsWith('|')) {
return 'OS support table does not have the expected header format';
}
// Utility method to convert an iterable of strings to a case-insensitive
// sorted, comma-separated string of its elements.
String sortedListString(Iterable<String> entries) {
final List<String> entryList = entries.toList();
entryList.sort(
(String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()));
return entryList.join(', ');
}
// Validate that the supported OS lists match.
final YamlMap pluginSection = pubspec.flutter!['plugin'] as YamlMap;
final dynamic platformsEntry = pluginSection['platforms'];
if (platformsEntry == null) {
logWarning('Plugin not support any platforms');
return null;
}
final YamlMap platformSupportMaps = platformsEntry as YamlMap;
final Set<String> actuallySupportedPlatform =
platformSupportMaps.keys.toSet().cast<String>();
final Iterable<String> documentedPlatforms = readmeLines[osLineNumber]
.split('|')
.map((String entry) => entry.trim())
.where((String entry) => entry.isNotEmpty);
final Set<String> documentedPlatformsLowercase =
documentedPlatforms.map((String entry) => entry.toLowerCase()).toSet();
if (actuallySupportedPlatform.length != documentedPlatforms.length ||
actuallySupportedPlatform
.intersection(documentedPlatformsLowercase)
.length !=
actuallySupportedPlatform.length) {
printError('''
${indentation}OS support table does not match supported platforms:
${indentation * 2}Actual: ${sortedListString(actuallySupportedPlatform)}
${indentation * 2}Documented: ${sortedListString(documentedPlatformsLowercase)}
''');
return 'Incorrect OS support table';
}
// Enforce a standard set of capitalizations for the OS headings.
final Iterable<String> incorrectCapitalizations = documentedPlatforms
.toSet()
.difference(_standardPlatformNames.values.toSet());
if (incorrectCapitalizations.isNotEmpty) {
final Iterable<String> expectedVersions = incorrectCapitalizations
.map((String name) => _standardPlatformNames[name.toLowerCase()]!);
printError('''
${indentation}Incorrect OS capitalization: ${sortedListString(incorrectCapitalizations)}
${indentation * 2}Please use standard capitalizations: ${sortedListString(expectedVersions)}
''');
return 'Incorrect OS support formatting';
}
// TODO(stuartmorgan): Add validation that the minimums in the table are
// consistent with what the current implementations require. See
// https://github.com/flutter/flutter/issues/84200
return null;
}
/// Validates [readmeLines], outputing error messages for any issue and
/// returning an array of error summaries (if any).
///
/// Returns an empty array if validation passes.
List<String> _validateBoilerplate(
List<String> readmeLines, {
required RepositoryPackage mainPackage,
required bool isExample,
}) {
final List<String> errors = <String>[];
if (_containsTemplateFlutterBoilerplate(readmeLines)) {
printError('${indentation}The boilerplate section about getting started '
'with Flutter should not be left in.');
errors.add('Contains template boilerplate');
}
// Enforce a repository-standard message in implementation plugin examples,
// since they aren't typical examples, which has been a source of
// confusion for plugin clients who find them.
if (isExample && mainPackage.isPlatformImplementation) {
if (_containsExampleBoilerplate(readmeLines)) {
printError('${indentation}The boilerplate should not be left in for a '
"federated plugin implementation package's example.");
errors.add('Contains template boilerplate');
}
if (!_containsImplementationExampleExplanation(readmeLines)) {
printError('${indentation}The example README for a platform '
'implementation package should warn readers about its intended '
'use. Please copy the example README from another implementation '
'package in this repository.');
errors.add('Missing implementation package example warning');
}
}
return errors;
}
/// Returns true if the README still has unwanted parts of the boilerplate
/// from the `flutter create` templates.
bool _containsTemplateFlutterBoilerplate(List<String> readmeLines) {
return readmeLines.any((String line) =>
line.contains('For help getting started with Flutter'));
}
/// Returns true if the README still has the generic description of an
/// example from the `flutter create` templates.
bool _containsExampleBoilerplate(List<String> readmeLines) {
return readmeLines
.any((String line) => line.contains('Demonstrates how to use the'));
}
/// Returns true if the README contains the repository-standard explanation of
/// the purpose of a federated plugin implementation's example.
bool _containsImplementationExampleExplanation(List<String> readmeLines) {
return (readmeLines.contains('# Platform Implementation Test App') &&
readmeLines.any(
(String line) => line.contains('This is a test app for'))) ||
(readmeLines.contains('# Platform Implementation Test Apps') &&
readmeLines.any(
(String line) => line.contains('These are test apps for')));
}
}