focus-flow/tool/check_reuse.dart

193 lines
6.3 KiB
Dart

// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Runs the local SPDX and REUSE metadata validation tool.
library;
import 'dart:io';
/// SPDX expression used by package and app source metadata.
///
/// The top-level `LICENSE.md` contains the GNU Affero General Public License
/// version 3 text, so the repository uses the corresponding SPDX identifier.
const _licenseIdentifier = 'AGPL-3.0-only';
/// Repository-local copyright text used in SPDX headers.
///
/// The project uses a contributor-oriented copyright holder because individual
/// source files do not currently carry more specific ownership notices.
const _copyrightText = '2026 FocusFlow contributors';
/// Package directories that must include a pointer to the root license file.
///
/// Each Dart package is publishable as its own unit even though publishing is
/// disabled, so every package folder carries a small `LICENSE.md` reference.
const _packageDirectories = <String>[
'packages/scheduler_backup',
'packages/scheduler_core',
'packages/scheduler_export',
'packages/scheduler_export_json',
'packages/scheduler_integration_tests',
'packages/scheduler_notifications',
'packages/scheduler_notifications_desktop',
'packages/scheduler_persistence',
'packages/scheduler_persistence_memory',
'packages/scheduler_persistence_sqlite',
];
/// App directories that must include a pointer to the root license file.
///
/// The Flutter app is outside the root Dart workspace, but it still ships with
/// repository code and therefore follows the same licensing metadata rules.
const _appDirectories = <String>['apps/focus_flow_flutter'];
/// Validates SPDX headers, REUSE metadata, and package/app license pointers.
void main(List<String> arguments) {
final failures = <String>[
..._reuseTomlFailures(),
..._licensePointerFailures(),
..._spdxHeaderFailures(),
];
if (failures.isEmpty) {
stdout.writeln('SPDX and REUSE metadata checks passed.');
return;
}
stderr.writeln('SPDX and REUSE metadata checks failed:');
for (final failure in failures) {
stderr.writeln('- $failure');
}
exitCode = 1;
}
/// Checks that `REUSE.toml` exists and covers package/app trees.
///
/// This does not replace the upstream REUSE tool, but it keeps the repository's
/// expected local metadata shape from drifting when contributors add files.
List<String> _reuseTomlFailures() {
final file = File('REUSE.toml');
if (!file.existsSync()) {
return <String>['REUSE.toml is missing.'];
}
final contents = file.readAsStringSync();
final failures = <String>[];
for (final required in <String>[
'packages/**',
'apps/focus_flow_flutter/**',
'SPDX-License-Identifier = "$_licenseIdentifier"',
'SPDX-FileCopyrightText = "$_copyrightText"',
]) {
if (!contents.contains(required)) {
failures.add('REUSE.toml does not contain `$required`.');
}
}
return failures;
}
/// Checks package and app `LICENSE.md` pointer files.
///
/// Pointer files keep each package understandable when opened in isolation while
/// still making the root `LICENSE.md` the single license text source.
List<String> _licensePointerFailures() {
final failures = <String>[];
for (final directory in <String>[
..._packageDirectories,
..._appDirectories
]) {
final file = File('$directory/LICENSE.md');
if (!file.existsSync()) {
failures.add('$directory/LICENSE.md is missing.');
continue;
}
final contents = file.readAsStringSync();
if (!contents.contains('../../LICENSE.md')) {
failures
.add('$directory/LICENSE.md does not reference ../../LICENSE.md.');
}
if (!contents.contains('SPDX-License-Identifier: $_licenseIdentifier')) {
failures.add('$directory/LICENSE.md is missing its SPDX license header.');
}
}
return failures;
}
/// Checks safe text files for inline SPDX headers.
///
/// Binary files, JSON resources, generated platform project files, and XML/plist
/// files are covered by `REUSE.toml` annotations because adding comments to
/// those formats can either be invalid or be overwritten by platform tooling.
List<String> _spdxHeaderFailures() {
final failures = <String>[];
for (final path in _trackedFiles()) {
if (!_requiresInlineSpdxHeader(path)) {
continue;
}
final contents = File(path).readAsStringSync();
if (!contents.contains('SPDX-License-Identifier: $_licenseIdentifier')) {
failures.add('$path is missing SPDX-License-Identifier.');
}
if (!contents.contains('SPDX-FileCopyrightText: $_copyrightText')) {
failures.add('$path is missing SPDX-FileCopyrightText.');
}
}
return failures;
}
/// Returns tracked repository files in stable path order.
///
/// Git is used so ignored build output and local editor files do not affect the
/// metadata check.
List<String> _trackedFiles() {
final result = Process.runSync(
'git',
<String>['ls-files'],
runInShell: Platform.isWindows,
);
if (result.exitCode != 0) {
throw ProcessException(
'git',
<String>['ls-files'],
'Unable to list tracked files.',
result.exitCode,
);
}
return (result.stdout as String)
.split('\n')
.where((path) => path.trim().isNotEmpty)
.toList(growable: false)
..sort();
}
/// Reports whether [path] should carry an inline SPDX header.
///
/// The allow-list is intentionally conservative and includes only formats where
/// adding a leading comment is safe and unlikely to break generated tooling.
bool _requiresInlineSpdxHeader(String path) {
if (!_isComplianceScope(path)) {
return false;
}
if (path.endsWith('.g.dart') || path.endsWith('LICENSE.md')) {
return false;
}
return path.endsWith('.dart') ||
path.endsWith('.yaml') ||
path.endsWith('.yml') ||
path.endsWith('.sh') ||
path.endsWith('.md');
}
/// Reports whether [path] belongs to the checked compliance surface.
///
/// The package and app folders are the main requested scope; root scripts and
/// tool files are included because they enforce the same standards.
bool _isComplianceScope(String path) {
return path.startsWith('packages/') ||
path.startsWith('apps/focus_flow_flutter/') ||
path.startsWith('scripts/') ||
path.startsWith('tool/') ||
path == 'pubspec.yaml' ||
path == 'analysis_options.yaml' ||
path == '.github/workflows/ci.yml';
}