// 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 main(List 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() .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', ['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> _trackedDartFilesMissingDeclarationDocs() async { final paths = await _trackedDartFiles(); final missing = []; 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> _trackedDartFiles() async { final result = await Process.run( 'git', ['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 _walkDartFiles() { final files = []; 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> _trackedDartFilesMissingFileDocs() async { final paths = await _trackedDartFiles(); final missing = []; 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 { /// 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 missing = []; /// 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); }