focus-flow/tool/check_docs.dart

391 lines
13 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 preferred so local build outputs, generated docs, and editor scratch
/// files do not affect the documentation gate. Container build contexts may omit
/// `.git`, so the tool falls back to a conservative walk over source folders.
Future<List<String>> _trackedDartFiles() async {
final result = await Process.run(
'git',
<String>['ls-files', '*.dart'],
runInShell: Platform.isWindows,
);
if (result.exitCode == 0) {
return (result.stdout as String)
.split('\n')
.where((path) => path.trim().isNotEmpty)
.toList(growable: false)
..sort();
}
return _walkDartFiles();
}
/// Walks source folders for Dart files when Git metadata is unavailable.
///
/// This fallback supports Earthly and other copied build contexts while still
/// avoiding generated output and tool caches that are not hand-maintained docs.
List<String> _walkDartFiles() {
final files = <String>[];
for (final entity in Directory.current.listSync(recursive: true)) {
if (entity is! File) {
continue;
}
final path = entity.path.replaceAll('\\', '/').replaceFirst('./', '');
if (!path.endsWith('.dart') ||
!_isDartAuditScope(path) ||
_isIgnoredGeneratedPath(path)) {
continue;
}
files.add(path);
}
return files..sort();
}
/// Reports whether [path] belongs to source folders covered by Dartdoc policy.
///
/// The root package, packages, scripts, tools, tests, and Flutter app are the
/// source surfaces where contributors add hand-maintained Dart code.
bool _isDartAuditScope(String path) {
return path.startsWith('packages/') ||
path.startsWith('apps/focus_flow_flutter/') ||
path.startsWith('scripts/') ||
path.startsWith('tool/') ||
path.startsWith('test/');
}
/// Reports whether [path] belongs to generated output or local tooling caches.
///
/// The fallback walker skips these paths because they are not source files and
/// usually are not present in Git's tracked file list.
bool _isIgnoredGeneratedPath(String path) {
return path.startsWith('.dart_tool/') ||
path.startsWith('.git/') ||
path.startsWith('build/') ||
path.startsWith('builds/') ||
path.startsWith('coverage/') ||
path.startsWith('doc/api/') ||
path.contains('/.dart_tool/') ||
path.contains('/build/') ||
path.contains('/.plugin_symlinks/');
}
/// 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);
}