forked from eva/focus-flow
346 lines
12 KiB
Dart
346 lines
12 KiB
Dart
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
/// Runs the Check Docs workspace validation tool.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:analyzer/dart/analysis/utilities.dart';
|
|
import 'package:analyzer/dart/ast/ast.dart';
|
|
import 'package:analyzer/dart/ast/visitor.dart';
|
|
import 'package:analyzer/source/line_info.dart';
|
|
|
|
/// Generates Dart docs for each package and fails on documentation gaps.
|
|
///
|
|
/// This tool is intentionally stricter than `dart doc` alone. It checks that
|
|
/// tracked Dart files have file-level library docs where appropriate and that
|
|
/// every declaration-like API surface has a Dartdoc comment, including private
|
|
/// helpers and part files. That keeps internal scheduler behavior readable for
|
|
/// future contributors instead of relying on project history or tribal context.
|
|
Future<void> main(List<String> arguments) async {
|
|
final skipDartdoc = arguments.contains('--skip-dartdoc');
|
|
final missingFileDocs = await _trackedDartFilesMissingFileDocs();
|
|
if (missingFileDocs.isNotEmpty) {
|
|
stderr.writeln('Dart files missing file-level Dartdoc library headers:');
|
|
for (final path in missingFileDocs) {
|
|
stderr.writeln('- $path');
|
|
}
|
|
}
|
|
|
|
final missingDeclarationDocs =
|
|
await _trackedDartFilesMissingDeclarationDocs();
|
|
if (missingDeclarationDocs.isNotEmpty) {
|
|
stderr.writeln('Dart declarations missing Dartdoc comments:');
|
|
for (final missing in missingDeclarationDocs) {
|
|
stderr.writeln('- $missing');
|
|
}
|
|
}
|
|
|
|
if (skipDartdoc) {
|
|
if (missingFileDocs.isNotEmpty || missingDeclarationDocs.isNotEmpty) {
|
|
stderr.writeln('Dart documentation quick gate failed.');
|
|
exitCode = 1;
|
|
} else {
|
|
stdout.writeln('Dart documentation quick gate passed.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
final outputRoot = Directory(_join(_join('doc', 'api'), 'packages'));
|
|
if (await outputRoot.exists()) {
|
|
await outputRoot.delete(recursive: true);
|
|
}
|
|
await outputRoot.create(recursive: true);
|
|
|
|
final packages = Directory('packages')
|
|
.listSync()
|
|
.whereType<Directory>()
|
|
.where((directory) {
|
|
return File(_join(directory.path, 'pubspec.yaml')).existsSync() &&
|
|
Directory(_join(directory.path, 'lib')).existsSync();
|
|
}).toList(growable: false)
|
|
..sort((left, right) => left.path.compareTo(right.path));
|
|
|
|
var failed = missingFileDocs.isNotEmpty || missingDeclarationDocs.isNotEmpty;
|
|
for (final package in packages) {
|
|
final outputPath = _join(outputRoot.absolute.path, _basename(package.path));
|
|
stdout.writeln('> dart doc --output $outputPath (${package.path})');
|
|
final result = await Process.run(
|
|
'dart',
|
|
<String>['doc', '--output', outputPath],
|
|
workingDirectory: package.path,
|
|
runInShell: Platform.isWindows,
|
|
);
|
|
final output = '${result.stdout}${result.stderr}';
|
|
stdout.write(output);
|
|
if (result.exitCode != 0 || _hasWarnings(output)) {
|
|
failed = true;
|
|
}
|
|
}
|
|
|
|
if (failed) {
|
|
stderr.writeln('Dart documentation gate failed.');
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
|
|
/// Finds tracked Dart declarations that do not have Dartdoc comments.
|
|
///
|
|
/// The audit includes classes, mixins, enums, enum values, extensions,
|
|
/// extension types, constructors, methods, top-level functions, fields, and
|
|
/// top-level variables. Generated files are skipped because their source of
|
|
/// truth is the generator configuration, not hand-maintained comments.
|
|
Future<List<String>> _trackedDartFilesMissingDeclarationDocs() async {
|
|
final paths = await _trackedDartFiles();
|
|
final missing = <String>[];
|
|
|
|
for (final path in paths) {
|
|
final file = File(path);
|
|
final contents = await file.readAsString();
|
|
if (_isGenerated(path, contents)) {
|
|
continue;
|
|
}
|
|
final result = parseString(
|
|
content: contents,
|
|
path: file.absolute.path,
|
|
throwIfDiagnostics: false,
|
|
);
|
|
final visitor = _DeclarationDocVisitor(path, result.unit.lineInfo);
|
|
result.unit.accept(visitor);
|
|
missing.addAll(visitor.missing);
|
|
}
|
|
|
|
return missing;
|
|
}
|
|
|
|
/// Returns tracked Dart files in stable path order.
|
|
///
|
|
/// Git is the source of truth so local build outputs, generated docs, and
|
|
/// editor scratch files do not affect the documentation gate.
|
|
Future<List<String>> _trackedDartFiles() async {
|
|
final result = await Process.run(
|
|
'git',
|
|
<String>['ls-files', '*.dart'],
|
|
runInShell: Platform.isWindows,
|
|
);
|
|
if (result.exitCode != 0) {
|
|
throw ProcessException(
|
|
'git',
|
|
<String>['ls-files', '*.dart'],
|
|
'Unable to list tracked Dart files.',
|
|
result.exitCode,
|
|
);
|
|
}
|
|
|
|
return (result.stdout as String)
|
|
.split('\n')
|
|
.where((path) => path.trim().isNotEmpty)
|
|
.toList(growable: false)
|
|
..sort();
|
|
}
|
|
|
|
/// Finds tracked Dart files that are missing file-level library docs.
|
|
///
|
|
/// Part files and generated files are excluded from this specific check because
|
|
/// they cannot declare their own `library;` header. Their declarations are still
|
|
/// covered by the declaration-level audit above.
|
|
Future<List<String>> _trackedDartFilesMissingFileDocs() async {
|
|
final paths = await _trackedDartFiles();
|
|
final missing = <String>[];
|
|
for (final path in paths) {
|
|
final file = File(path);
|
|
final contents = await file.readAsString();
|
|
if (_isGeneratedPart(path, contents)) {
|
|
continue;
|
|
}
|
|
if (!_hasFileLevelLibraryDocs(contents)) {
|
|
missing.add(path);
|
|
}
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
/// Reports whether a file is generated or a library part.
|
|
///
|
|
/// SPDX headers may appear before `part of`, so the check removes the standard
|
|
/// leading SPDX block before identifying part files.
|
|
bool _isGeneratedPart(String path, String contents) {
|
|
final source = _stripLeadingSpdxHeader(contents).trimLeft();
|
|
return _isGenerated(path, contents) || source.startsWith('part of ');
|
|
}
|
|
|
|
/// Reports whether a file should be treated as generated source.
|
|
///
|
|
/// Generated sources are not edited by hand, so enforcing hand-written docs on
|
|
/// them would create churn every time the generator runs.
|
|
bool _isGenerated(String path, String contents) {
|
|
return path.endsWith('.g.dart') ||
|
|
contents.contains('// GENERATED CODE - DO NOT MODIFY BY HAND');
|
|
}
|
|
|
|
/// Reports whether a Dart file starts with a Dartdoc library header.
|
|
///
|
|
/// SPDX headers are allowed to precede the docs so files can satisfy both REUSE
|
|
/// metadata conventions and Dartdoc's library-level documentation expectations.
|
|
bool _hasFileLevelLibraryDocs(String contents) {
|
|
final source = _stripLeadingSpdxHeader(contents);
|
|
return RegExp(
|
|
r'^\s*///[\s\S]*?\nlibrary;',
|
|
multiLine: false,
|
|
).hasMatch(source);
|
|
}
|
|
|
|
/// Removes the standard leading SPDX comment block from a Dart source string.
|
|
///
|
|
/// The function deliberately handles only SPDX and copyright line comments at
|
|
/// the top of the file; ordinary explanatory comments remain visible to the
|
|
/// file-level documentation check.
|
|
String _stripLeadingSpdxHeader(String contents) {
|
|
return contents.replaceFirst(
|
|
RegExp(
|
|
r'^(?:(?://\s*SPDX-[^\n]*|//\s*Copyright[^\n]*|//\s*$)\n)*',
|
|
multiLine: false,
|
|
),
|
|
'',
|
|
);
|
|
}
|
|
|
|
/// Visits Dart declarations and records any declaration without Dartdoc.
|
|
///
|
|
/// The visitor intentionally ignores local variables and local functions inside
|
|
/// method bodies. Those are implementation details where inline comments should
|
|
/// remain rare and reserved for unusually complex logic.
|
|
class _DeclarationDocVisitor extends RecursiveAstVisitor<void> {
|
|
/// Creates a visitor for one parsed Dart file.
|
|
///
|
|
/// The [lineInfo] object is reused for every finding so error messages can
|
|
/// point directly at the declaration line that needs documentation.
|
|
_DeclarationDocVisitor(this.path, this.lineInfo);
|
|
|
|
/// Repository-relative path for the file currently being audited.
|
|
final String path;
|
|
|
|
/// Line lookup table produced by the analyzer parser.
|
|
final LineInfo lineInfo;
|
|
|
|
/// Human-readable documentation findings for this file.
|
|
final List<String> missing = <String>[];
|
|
|
|
/// Checks class declarations for Dartdoc.
|
|
@override
|
|
void visitClassDeclaration(ClassDeclaration node) {
|
|
_check(node, 'class');
|
|
super.visitClassDeclaration(node);
|
|
}
|
|
|
|
/// Checks mixin declarations for Dartdoc.
|
|
@override
|
|
void visitMixinDeclaration(MixinDeclaration node) {
|
|
_check(node, 'mixin');
|
|
super.visitMixinDeclaration(node);
|
|
}
|
|
|
|
/// Checks enum declarations for Dartdoc.
|
|
@override
|
|
void visitEnumDeclaration(EnumDeclaration node) {
|
|
_check(node, 'enum');
|
|
super.visitEnumDeclaration(node);
|
|
}
|
|
|
|
/// Checks enum constants for Dartdoc.
|
|
@override
|
|
void visitEnumConstantDeclaration(EnumConstantDeclaration node) {
|
|
_check(node, 'enum value');
|
|
super.visitEnumConstantDeclaration(node);
|
|
}
|
|
|
|
/// Checks extension declarations for Dartdoc.
|
|
@override
|
|
void visitExtensionDeclaration(ExtensionDeclaration node) {
|
|
_check(node, 'extension');
|
|
super.visitExtensionDeclaration(node);
|
|
}
|
|
|
|
/// Checks extension type declarations for Dartdoc.
|
|
@override
|
|
void visitExtensionTypeDeclaration(ExtensionTypeDeclaration node) {
|
|
_check(node, 'extension type');
|
|
super.visitExtensionTypeDeclaration(node);
|
|
}
|
|
|
|
/// Checks constructor declarations for Dartdoc.
|
|
@override
|
|
void visitConstructorDeclaration(ConstructorDeclaration node) {
|
|
_check(node, 'constructor');
|
|
super.visitConstructorDeclaration(node);
|
|
}
|
|
|
|
/// Checks class, mixin, extension, and enum methods for Dartdoc.
|
|
@override
|
|
void visitMethodDeclaration(MethodDeclaration node) {
|
|
_check(node, 'method');
|
|
super.visitMethodDeclaration(node);
|
|
}
|
|
|
|
/// Checks only top-level functions for Dartdoc.
|
|
@override
|
|
void visitFunctionDeclaration(FunctionDeclaration node) {
|
|
if (node.parent is CompilationUnit) {
|
|
_check(node, 'top-level function');
|
|
}
|
|
super.visitFunctionDeclaration(node);
|
|
}
|
|
|
|
/// Checks instance and static fields for Dartdoc.
|
|
@override
|
|
void visitFieldDeclaration(FieldDeclaration node) {
|
|
_check(node, 'field');
|
|
super.visitFieldDeclaration(node);
|
|
}
|
|
|
|
/// Checks top-level variables for Dartdoc.
|
|
@override
|
|
void visitTopLevelVariableDeclaration(TopLevelVariableDeclaration node) {
|
|
_check(node, 'top-level variable');
|
|
super.visitTopLevelVariableDeclaration(node);
|
|
}
|
|
|
|
/// Adds a missing-doc finding for [node] when it has no documentation comment.
|
|
void _check(Declaration node, String kind) {
|
|
if (node.documentationComment != null) {
|
|
return;
|
|
}
|
|
final location = lineInfo.getLocation(node.offset);
|
|
final summary = node.toSource().split('\n').first.trim();
|
|
missing.add('$path:${location.lineNumber}: $kind `$summary`');
|
|
}
|
|
}
|
|
|
|
/// Reports whether `dart doc` produced warning text.
|
|
///
|
|
/// Dartdoc may return success while still reporting warnings, and this project
|
|
/// treats warnings as documentation failures so broken references are caught
|
|
/// before they reach CI or generated docs.
|
|
bool _hasWarnings(String output) {
|
|
return output.contains(' warning:') ||
|
|
RegExp(r'Found [1-9][0-9]* warnings').hasMatch(output);
|
|
}
|
|
|
|
/// Joins two path segments using the platform separator.
|
|
///
|
|
/// The helper avoids pulling in another dependency for the small amount of path
|
|
/// handling needed by this repository-local script.
|
|
String _join(String first, String second) {
|
|
final separator = Platform.pathSeparator;
|
|
return '$first$separator$second';
|
|
}
|
|
|
|
/// Returns the final path segment for a platform-neutral path string.
|
|
///
|
|
/// Dartdoc output directory names use package basenames, so this helper keeps
|
|
/// the script independent of whether Git produced slash or backslash paths.
|
|
String _basename(String path) {
|
|
final normalized = path.replaceAll('\\', '/');
|
|
return normalized.substring(normalized.lastIndexOf('/') + 1);
|
|
}
|