diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/SKILL.md b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/SKILL.md index fb14888cf2e6..bfe4d5f6bc2a 100644 --- a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/SKILL.md +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/SKILL.md @@ -9,9 +9,9 @@ metadata: This skill verifies that the local environment is properly configured and clean before starting new work in the `camera_android_camerax` package. ## Instructions -Run the bundled verification script ([scripts/check.sh](scripts/check.sh)) to perform the automated environment checks: +Run the bundled verification script ([tool/check.dart](tool/check.dart)) to perform the automated environment checks: ```bash -bash .agents/skills/check-readiness/scripts/check.sh +dart run .agents/skills/check-readiness/tool/check.dart ``` ### Handling the Results diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/dart_test.yaml b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/dart_test.yaml new file mode 100644 index 000000000000..91ec220b8e22 --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/dart_test.yaml @@ -0,0 +1 @@ +test_on: vm diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/lib/check_readiness.dart b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/lib/check_readiness.dart new file mode 100644 index 000000000000..d64f5cd7762e --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/lib/check_readiness.dart @@ -0,0 +1,140 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:process/process.dart'; + +/// Checks if the environment is ready for new work. +class ReadinessChecker { + /// Creates a new ReadinessChecker. + ReadinessChecker({ + FileSystem? fileSystem, + ProcessManager? processManager, + void Function(Object?)? log, + }) : _fileSystem = fileSystem ?? const LocalFileSystem(), + _processManager = processManager ?? const LocalProcessManager(), + _log = log ?? ((Object? msg) => stdout.writeln(msg)); + + final FileSystem _fileSystem; + final ProcessManager _processManager; + final void Function(Object?) _log; + + /// Runs all readiness checks. + /// + /// Returns `true` if ready, `false` otherwise. + Future checkReadiness(String workspaceRoot) async { + _log('Checking if environment is ready for new work...'); + + var isReady = true; + + if (!await _checkSymlinks(workspaceRoot)) { + isReady = false; + } + if (!await _checkGitState(workspaceRoot)) { + isReady = false; + } + + final bool hasTools = await _checkFlutterAndDart(); + if (!hasTools) { + isReady = false; + } else { + if (!await _checkDependencies(workspaceRoot)) { + isReady = false; + } + } + + if (isReady) { + _log('Environment is fully ready!'); + } + return isReady; + } + + Future _checkSymlinks(String workspaceRoot) async { + _log('1. Checking skill symlinks...'); + final Directory agentsDir = + _fileSystem.directory(_fileSystem.path.join(workspaceRoot, '.agents', 'skills')); + if (!agentsDir.existsSync()) { + // If it doesn't exist, there are no broken symlinks. + _log('All symlinks resolve correctly.'); + return true; + } + + final brokenLinks = []; + await for (final FileSystemEntity entity + in agentsDir.list(recursive: true, followLinks: false)) { + if (entity is Link) { + if (_fileSystem.typeSync(entity.path) == FileSystemEntityType.notFound) { + brokenLinks.add(entity.path); + } + } + } + + if (brokenLinks.isNotEmpty) { + _log('Error: Found broken symlinks in .agents/skills:'); + brokenLinks.forEach(_log); + return false; + } + + _log('All symlinks resolve correctly.'); + return true; + } + + Future _checkGitState(String workspaceRoot) async { + _log('2. Checking git state...'); + final ProcessResult result; + try { + result = await _processManager.run( + ['git', 'status', '--porcelain'], + workingDirectory: workspaceRoot, + ); + } on ProcessException catch (e) { + _log('Error: Failed to run git status. Is git installed and on the PATH?'); + _log(e.toString()); + return false; + } + if (result.exitCode != 0) { + _log('Error: Failed to run git status.'); + return false; + } + final String stdoutStr = (result.stdout as String).trim(); + if (stdoutStr.isNotEmpty) { + _log( + 'Error: Git working directory is not clean. Please commit or stash your changes before starting new work.'); + return false; + } + _log('Git working directory is clean.'); + return true; + } + + Future _checkFlutterAndDart() async { + _log('3. Checking Flutter and Dart...'); + if (!_processManager.canRun('flutter')) { + _log("Error: 'flutter' is not on the PATH."); + return false; + } + if (!_processManager.canRun('dart')) { + _log("Error: 'dart' is not on the PATH."); + return false; + } + _log('Flutter and Dart are on the PATH.'); + return true; + } + + Future _checkDependencies(String workspaceRoot) async { + _log('4. Checking dependencies in camera_android_camerax...'); + final ProcessResult result = await _processManager.run( + ['flutter', 'pub', 'get'], + workingDirectory: workspaceRoot, + ); + if (result.exitCode != 0) { + _log('Error: Failed to resolve dependencies.'); + return false; + } + _log('Dependencies are resolved and ready.'); + return true; + } +} diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/pubspec.yaml b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/pubspec.yaml new file mode 100644 index 000000000000..289c8be9ec72 --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/pubspec.yaml @@ -0,0 +1,17 @@ +name: check_readiness +description: A tool to check if the repository is ready for new work, intended only for use in the check-readiness skill. +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.0.0 + +dependencies: + file: ^7.0.1 + path: ^1.9.1 + process: ^5.0.5 + +dev_dependencies: + build_runner: ^2.15.0 + mockito: ^5.7.0 + test: ^1.24.0 diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/scripts/check.sh b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/scripts/check.sh deleted file mode 100755 index beeb6482f8c9..000000000000 --- a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/scripts/check.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -# Copyright 2013 The Flutter Authors -# Use of this source code is governed by a BSD-style license that can be -# found in the LICENSE file. - -# Stop on first error -set -e - -# Get the directory of this script, then go up to camera_android_camerax root -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -CAMERAX_DIR="$SCRIPT_DIR/../../../.." - -echo "🔍 Checking if environment is ready for new work..." - -# 1. Check symlinks resolve -echo "1️⃣ Checking skill symlinks..." -broken_links=$(find "$CAMERAX_DIR/.agents/skills" -type l ! -exec test -e {} \; -print) -if [ -n "$broken_links" ]; then - echo "❌ Error: Found broken symlinks in .agents/skills:" - echo "$broken_links" - exit 1 -fi -echo "✅ All symlinks resolve correctly." - -# 2. Check git state -echo "2️⃣ Checking git state..." -# Check the whole repository git state -if [ -n "$(git status --porcelain)" ]; then - echo "❌ Error: Git working directory is not clean. Please commit or stash your changes before starting new work." - exit 1 -fi -echo "✅ Git working directory is clean." - -# 3. Check dart and flutter -echo "3️⃣ Checking Flutter and Dart..." -if ! command -v flutter &> /dev/null; then - echo "❌ Error: 'flutter' is not on the PATH." - exit 1 -fi -if ! command -v dart &> /dev/null; then - echo "❌ Error: 'dart' is not on the PATH." - exit 1 -fi -echo "✅ Flutter and Dart are on the PATH." - -# 4. Check dependencies in camera_android_camerax -echo "4️⃣ Checking dependencies in camera_android_camerax..." -cd "$CAMERAX_DIR" -if ! flutter pub get; then - echo "❌ Error: Failed to resolve dependencies." - exit 1 -fi -echo "✅ Dependencies are resolved and ready." - -echo "🎉 Environment is fully ready!" diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart new file mode 100644 index 000000000000..f6a192043a2d --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart @@ -0,0 +1,234 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:io' as io; + +import 'package:check_readiness/check_readiness.dart'; +import 'package:file/memory.dart'; +import 'package:file/src/interface/directory.dart'; +import 'package:file/src/interface/link.dart'; +import 'package:path/path.dart' as p; +import 'package:process/process.dart'; +import 'package:test/test.dart'; + +import '../tool/check.dart'; + +class FakeProcessManager implements ProcessManager { + final Map canRunMock = {}; + final Map runMock = {}; + final List> runInvocations = []; + + @override + bool canRun(dynamic executable, {String? workingDirectory}) { + return canRunMock[executable as String] ?? true; + } + + @override + Future run( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, + }) async { + final List cmdList = command.cast(); + runInvocations.add(cmdList); + final String key = cmdList.join(' '); + if (runMock.containsKey(key)) { + return runMock[key]!; + } + return ProcessResult(0, 0, '', ''); + } + + // The rest of the interface is unimplemented. + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late MemoryFileSystem fileSystem; + late FakeProcessManager processManager; + late ReadinessChecker checker; + late String workspaceRoot; + final List printLogs = []; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + processManager = FakeProcessManager(); + checker = ReadinessChecker( + fileSystem: fileSystem, + processManager: processManager, + log: (Object? message) => printLogs.add(message.toString()), + ); + workspaceRoot = fileSystem.path.absolute('workspace'); + printLogs.clear(); + }); + + /// Runs the checker and captures prints + Future runChecker() async { + return checker.checkReadiness(workspaceRoot); + } + + test('passes when everything is correct', () async { + // Setup empty skills dir (no broken symlinks) + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + // Git returns clean + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, '', ''); + + final bool result = await runChecker(); + expect(result, isTrue); + expect(printLogs, contains('Environment is fully ready!')); + }); + + test('fails when a broken symlink is present', () async { + final Directory skillsDir = fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + ..createSync(recursive: true); + + // MemoryFileSystem supports links + final Link link = fileSystem.link(fileSystem.path.join(skillsDir.path, 'broken_link')); + link.createSync('non_existent_target'); + + final bool result = await runChecker(); + expect(result, isFalse); + expect( + printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), isTrue); + }); + + test('fails when git is dirty', () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); + + final bool result = await runChecker(); + expect(result, isFalse); + expect( + printLogs, + contains( + 'Error: Git working directory is not clean. Please commit or stash your changes before starting new work.')); + }); + + test('fails when flutter is missing', () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + processManager.canRunMock['flutter'] = false; + + final bool result = await runChecker(); + expect(result, isFalse); + expect(printLogs, contains("Error: 'flutter' is not on the PATH.")); + }); + + test('fails when dart is missing', () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + processManager.canRunMock['dart'] = false; + + final bool result = await runChecker(); + expect(result, isFalse); + expect(printLogs, contains("Error: 'dart' is not on the PATH.")); + }); + + test('fails when flutter pub get fails', () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, '', ''); + processManager.runMock['flutter pub get'] = ProcessResult(0, 1, '', 'Error'); + + final bool result = await runChecker(); + expect(result, isFalse); + expect(printLogs, contains('Error: Failed to resolve dependencies.')); + }); + + test('does not return early and reports multiple failures if git is dirty and tools are missing', + () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + // Git returns dirty + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); + // Flutter is missing + processManager.canRunMock['flutter'] = false; + + final bool result = await runChecker(); + expect(result, isFalse); + + // Both errors should be printed + expect(printLogs.any((line) => line.contains('Git working directory is not clean')), isTrue); + expect(printLogs.any((line) => line.contains("'flutter' is not on the PATH")), isTrue); + }); + + group('Windows style', () { + late MemoryFileSystem winFileSystem; + late ReadinessChecker winChecker; + late String winWorkspaceRoot; + + setUp(() { + winFileSystem = MemoryFileSystem(style: FileSystemStyle.windows); + winChecker = ReadinessChecker( + fileSystem: winFileSystem, + processManager: processManager, + log: (Object? message) => printLogs.add(message.toString()), + ); + winWorkspaceRoot = r'C:\workspace'; + printLogs.clear(); + }); + + test('fails when a broken symlink is present on Windows', () async { + final Directory skillsDir = winFileSystem + .directory(winFileSystem.path.join(winWorkspaceRoot, '.agents', 'skills')) + ..createSync(recursive: true); + + final Link link = winFileSystem.link(winFileSystem.path.join(skillsDir.path, 'broken_link')); + link.createSync('non_existent_target'); + + final bool result = await winChecker.checkReadiness(winWorkspaceRoot); + expect(result, isFalse); + expect(printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), + isTrue); + }); + }); + + group('findPackageDir', () { + test('correctly resolves package root from check.dart path', () { + final String scriptPath = p.joinAll([ + 'repo', + 'packages', + 'camera', + 'camera_android_camerax', + '.agents', + 'skills', + 'check-readiness', + 'tool', + 'check.dart', + ]); + final scriptUri = Uri.file(scriptPath); + final io.Directory resolved = findPackageDir(scriptUri); + + final String expectedPath = p.joinAll([ + 'repo', + 'packages', + 'camera', + 'camera_android_camerax', + ]); + expect(resolved.path, expectedPath); + }); + }); +} diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/tool/check.dart b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/tool/check.dart new file mode 100644 index 000000000000..ce7e8f784362 --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/tool/check.dart @@ -0,0 +1,25 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:check_readiness/check_readiness.dart'; +import 'package:path/path.dart' as p; + +/// Resolves the package root directory from the script URI. +Directory findPackageDir(Uri scriptUri) { + final scriptDir = Directory(p.dirname(scriptUri.toFilePath())); + // The script is at: .agents/skills/check-readiness/tool/check.dart + // Going up 4 levels to get the camera_android_camerax package root. + return scriptDir.parent.parent.parent.parent; +} + +Future main(List args) async { + final Directory packageDir = findPackageDir(Platform.script); + final String packageRoot = packageDir.path; + + final checker = ReadinessChecker(); + final bool isReady = await checker.checkReadiness(packageRoot); + exitCode = isReady ? 0 : 1; +} diff --git a/packages/camera/camera_android_camerax/ci_config.yaml b/packages/camera/camera_android_camerax/ci_config.yaml new file mode 100644 index 000000000000..c71f578cf0e6 --- /dev/null +++ b/packages/camera/camera_android_camerax/ci_config.yaml @@ -0,0 +1 @@ +analyze_skills: true diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 5fe1ee9e0929..3538e3ed5c21 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -305,17 +305,65 @@ class AnalyzeCommand extends PackageLoopingCommand { if (_hasUnexpectedAnalysisOptions(package)) { return PackageResult.fail(['Unexpected local analysis options']); } - final int exitCode = await processRunner.runAndStream(_dartBinaryPath, [ + final int mainExitCode = await processRunner.runAndStream(_dartBinaryPath, [ 'analyze', '--fatal-infos', if (libOnly) 'lib', ], workingDir: package.directory); - if (exitCode != 0) { - return PackageResult.fail(); + + var skillsExitCode = 0; + var skillsErrors = []; + if (!libOnly && (package.parseCIConfig()?.analyzeSkills ?? false)) { + skillsErrors = _validateAgentsSkillsDirectory(package); + if (skillsErrors.isEmpty) { + skillsExitCode = await processRunner.runAndStream(_dartBinaryPath, [ + 'analyze', + '--fatal-infos', + '.agents/skills', + ], workingDir: package.directory); + } + } + + final errors = [ + if (mainExitCode != 0) 'Main package analysis failed', + if (skillsExitCode != 0) 'Skills analysis failed', + ...skillsErrors, + ]; + + if (errors.isNotEmpty) { + return PackageResult.fail(errors); } return PackageResult.success(); } + /// Validates that `.agents/skills` contains Dart code if configured for skills analysis. + /// + /// Returns a list of error strings if the package is configured for skills analysis + /// but no Dart code was found. Returns an empty list on success. + List _validateAgentsSkillsDirectory(RepositoryPackage package) { + bool hasDartFiles(Directory dir) { + if (!dir.existsSync()) { + return false; + } + return dir + .listSync(recursive: true) + .any((FileSystemEntity entity) => entity is File && entity.path.endsWith('.dart')); + } + + final Directory agentsSkillsDir = package.directory + .childDirectory('.agents') + .childDirectory('skills'); + + if (!hasDartFiles(agentsSkillsDir)) { + printError( + 'Configured to analyze skills for ${package.directory.basename}, but no Dart code was found in .agents/skills.', + ); + return ['No Dart code found in .agents/skills']; + } + + return []; + } + Future _runPubCommand(RepositoryPackage package, String command) async { return runPubCommand( [command], diff --git a/script/tool/lib/src/common/ci_config.dart b/script/tool/lib/src/common/ci_config.dart index aa7b38904529..f7c9ca34e641 100644 --- a/script/tool/lib/src/common/ci_config.dart +++ b/script/tool/lib/src/common/ci_config.dart @@ -7,7 +7,11 @@ import 'package:yaml/yaml.dart'; /// A class representing the parsed content of a `ci_config.yaml` file. class CIConfig { /// Creates a [CIConfig] from a parsed YAML map. - CIConfig._({required this.isBatchRelease, required this.requiresExcerpts}); + CIConfig._({ + required this.isBatchRelease, + required this.requiresExcerpts, + required this.analyzeSkills, + }); /// Parses a [CIConfig] from a YAML string. /// @@ -30,7 +34,13 @@ class CIConfig { // excerpts. final requiresExcerpts = loaded['exempt_from_excerpts'] != true; - return CIConfig._(isBatchRelease: isBatchRelease, requiresExcerpts: requiresExcerpts); + final analyzeSkills = loaded['analyze_skills'] == true; + + return CIConfig._( + isBatchRelease: isBatchRelease, + requiresExcerpts: requiresExcerpts, + analyzeSkills: analyzeSkills, + ); } static const Map _validCIConfigSyntax = { @@ -38,6 +48,7 @@ class CIConfig { 'batch': {true, false}, }, 'exempt_from_excerpts': {true, false}, + 'analyze_skills': {true, false}, }; /// Returns true if the package is configured for batch release. @@ -46,6 +57,9 @@ class CIConfig { /// Returns true if the package is configured to require excerpts. final bool requiresExcerpts; + /// Returns true if the package has its agent skills analyzed. + final bool analyzeSkills; + static void _checkCIConfigEntries( YamlMap config, { required Map syntax, diff --git a/script/tool/lib/src/common/package_state_utils.dart b/script/tool/lib/src/common/package_state_utils.dart index c2a2735d9cf5..f6b053d18462 100644 --- a/script/tool/lib/src/common/package_state_utils.dart +++ b/script/tool/lib/src/common/package_state_utils.dart @@ -191,6 +191,8 @@ Future _isDevChange( String? repoPath, }) async { return _isTestChange(pathComponents) || + // Agent directories (.agents/) are for developer-only utility tools (skills, scripts). + pathComponents.first == '.agents' || // The top-level "tool" directory is for non-client-facing utility // code, such as test scripts. pathComponents.first == 'tool' || diff --git a/script/tool/lib/src/common/repository_package.dart b/script/tool/lib/src/common/repository_package.dart index 2dbbe6fa9d31..92f775917c1f 100644 --- a/script/tool/lib/src/common/repository_package.dart +++ b/script/tool/lib/src/common/repository_package.dart @@ -3,11 +3,13 @@ // found in the LICENSE file. import 'package:file/file.dart'; +import 'package:glob/glob.dart'; import 'package:path/path.dart' as p; import 'package:pubspec_parse/pubspec_parse.dart'; import 'ci_config.dart'; import 'core.dart'; +import 'output_utils.dart'; import 'pending_changelog_entry.dart'; export 'package:pubspec_parse/pubspec_parse.dart' show Pubspec; @@ -200,20 +202,75 @@ class RepositoryPackage { } /// Returns the package that this package is a part of, if any. - /// - /// Currently this is limited to checking up two directories, since that - /// covers all the example structures currently used. RepositoryPackage? getEnclosingPackage() { - final Directory parent = directory.parent; - if (isPackage(parent)) { - return RepositoryPackage(parent); - } - if (isPackage(parent.parent)) { - return RepositoryPackage(parent.parent); + Directory current = directory.parent; + while (current.path != current.parent.path) { + if (isPackage(current)) { + return RepositoryPackage(current); + } + // Stop walking up if we hit a known repo root directory. + if (current.basename == 'packages' || current.basename == 'third_party') { + break; + } + current = current.parent; } return null; } + /// True if this package is located within a directory that is ignored + /// by the enclosing package's `.pubignore` file. + bool get isPubIgnored { + final RepositoryPackage? enclosingPackage = getEnclosingPackage(); + if (enclosingPackage == null) { + return false; + } + final File pubignoreFile = enclosingPackage.directory.childFile('.pubignore'); + if (!pubignoreFile.existsSync()) { + return false; + } + + final String relativePath = p.relative(directory.path, from: enclosingPackage.directory.path); + final List segments = p.split(relativePath); + final candidatePaths = []; + for (var i = 1; i <= segments.length; i++) { + final String prefix = p.posix.joinAll(segments.sublist(0, i)); + candidatePaths.add(prefix); + candidatePaths.add('$prefix/'); + } + + final List ignoreLines = pubignoreFile.readAsLinesSync(); + + for (final line in ignoreLines) { + final String trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + + var pattern = trimmed; + final bool isAnchored = pattern.startsWith('/'); + if (isAnchored) { + pattern = pattern.substring(1); + } + if (pattern.endsWith('/')) { + pattern = '$pattern**'; + } + + try { + final globExact = Glob(pattern); + final Glob? globNested = isAnchored ? null : Glob('**/$pattern'); + for (final candidate in candidatePaths) { + if (globExact.matches(candidate) || + (globNested != null && globNested.matches(candidate))) { + return true; + } + } + } on FormatException catch (e) { + printWarning('Warning: Invalid glob pattern "$trimmed" in ${pubignoreFile.path}: $e'); + } + } + return false; + } + /// Returns all Dart package folders (e.g., examples) under this package. Iterable getSubpackages({bool includeExamples = true}) { return directory diff --git a/script/tool/lib/src/validate_command.dart b/script/tool/lib/src/validate_command.dart index 7f3322e4da9f..78ac7cacf18a 100644 --- a/script/tool/lib/src/validate_command.dart +++ b/script/tool/lib/src/validate_command.dart @@ -171,6 +171,12 @@ class ValidateCommand extends PackageLoopingCommand { @override Future runForPackage(RepositoryPackage package) async { + // Packages excluded via .pubignore are not published, consumer-facing + // artifacts, so they are exempt from the hygiene checks enforced by this command. + if (package.isPubIgnored) { + return PackageResult.skip('Ignored by .pubignore'); + } + final List errors = [ if (_shouldRun(Validator.repoInfo)) ...await _validateRepoInfo(package), if (_shouldRun(Validator.pubspec)) ...await _validatePubspec(package), diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index ed259156675f..4fe9e7a2f1e9 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -10,6 +10,7 @@ dependencies: colorize: ^3.0.0 file: ^7.0.1 git: ^2.0.0 + glob: ^2.1.3 http: ^1.0.0 http_multi_server: ^3.0.1 meta: ^1.10.0 diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 7a45616a8325..c4f5664ec0da 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -442,6 +442,117 @@ void main() { }); }); + group('skills analysis via ci_config.yaml', () { + test('fails if package configuration has no dart files in .agents/skills', () async { + final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); + plugin.ciConfigFile.writeAsStringSync('analyze_skills: true'); + + // Note: we purposely do not create any .dart files in .agents/skills + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['analyze'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Configured to analyze skills for foo, but no Dart code was found in .agents/skills.', + ), + ]), + ); + }); + + test( + 'analyzes .agents/skills when dart files are present and analyze_skills is true', + () async { + final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); + plugin.ciConfigFile.writeAsStringSync('analyze_skills: true'); + + plugin.directory + .childDirectory('.agents') + .childDirectory('skills') + .childFile('test.dart') + .createSync(recursive: true); + + await runCapturingPrint(runner, ['analyze']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall('flutter', const ['pub', 'get'], plugin.path), + ProcessCall('dart', const ['analyze', '--fatal-infos'], plugin.path), + ProcessCall('dart', const [ + 'analyze', + '--fatal-infos', + '.agents/skills', + ], plugin.path), + ]), + ); + }, + ); + + test('does not analyze .agents/skills when analyze_skills is false/omitted', () async { + final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); + + plugin.directory + .childDirectory('.agents') + .childDirectory('skills') + .childFile('test.dart') + .createSync(recursive: true); + + await runCapturingPrint(runner, ['analyze']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall('flutter', const ['pub', 'get'], plugin.path), + ProcessCall('dart', const ['analyze', '--fatal-infos'], plugin.path), + ]), + ); + }); + + test('fails with both main and skills failures listed if both fail', () async { + final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); + plugin.ciConfigFile.writeAsStringSync('analyze_skills: true'); + + plugin.directory + .childDirectory('.agents') + .childDirectory('skills') + .childFile('test.dart') + .createSync(recursive: true); + + // Mock both dart analyze runs to fail (exit code 1) + processRunner.mockProcessesForExecutable['dart'] = [ + FakeProcessInfo(MockProcess(exitCode: 1), ['analyze']), // main package + FakeProcessInfo(MockProcess(exitCode: 1), ['analyze']), // skills package + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['analyze'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + final String joinedOutput = output.join('\n'); + expect(joinedOutput, contains('The following packages had errors:')); + expect( + joinedOutput, + contains(' foo:\n Main package analysis failed\n Skills analysis failed'), + ); + }); + }); + test('skips if requested if "pub get" fails in the resolver', () async { final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); diff --git a/script/tool/test/common/ci_config_test.dart b/script/tool/test/common/ci_config_test.dart index 503a166aabc8..8c07af2a4bfa 100644 --- a/script/tool/test/common/ci_config_test.dart +++ b/script/tool/test/common/ci_config_test.dart @@ -28,12 +28,14 @@ void main() { plugin.ciConfigFile.writeAsStringSync(''' release: batch: true +analyze_skills: true '''); final CIConfig? config = plugin.parseCIConfig(); expect(config, isNotNull); expect(config!.isBatchRelease, isTrue); + expect(config.analyzeSkills, isTrue); }); test('parsing missing file returns null', () async { diff --git a/script/tool/test/common/package_state_utils_test.dart b/script/tool/test/common/package_state_utils_test.dart index 19758a77d948..fb00fb53aea2 100644 --- a/script/tool/test/common/package_state_utils_test.dart +++ b/script/tool/test/common/package_state_utils_test.dart @@ -98,6 +98,25 @@ void main() { expect(state.hasChangelogChange, true); }); + test('does not require version or changelog change for .agents changes', () async { + final RepositoryPackage package = createFakePlugin('a_plugin', packagesDir); + + const changedFiles = [ + 'packages/a_plugin/.agents/skills/check-readiness/bin/check.dart', + 'packages/a_plugin/.agents/skills/check-readiness/pubspec.yaml', + ]; + + final PackageChangeState state = await checkPackageChangeState( + package, + changedPaths: changedFiles, + relativePackagePath: 'packages/a_plugin/', + ); + + expect(state.hasChanges, true); + expect(state.needsVersionChange, false); + expect(state.needsChangelogChange, false); + }); + test('only considers a root "tool" folder to be special', () async { final RepositoryPackage package = createFakePlugin('a_plugin', packagesDir); diff --git a/script/tool/test/common/repository_package_test.dart b/script/tool/test/common/repository_package_test.dart index 3e65b415a4e5..7ccfb123eaf5 100644 --- a/script/tool/test/common/repository_package_test.dart +++ b/script/tool/test/common/repository_package_test.dart @@ -184,6 +184,110 @@ void main() { expect(plugin.isExample, isFalse); }); }); + group('isPubIgnored', () { + test('returns false if there is no enclosing package', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + expect(package.isPubIgnored, false); + }); + + test('returns false if there is no .pubignore file', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + final RepositoryPackage subPackage = createFakePackage('sub_package', package.directory); + expect(subPackage.isPubIgnored, false); + }); + + test('returns true if the package is in an ignored directory (with trailing slash)', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('.agents/'); + + final Directory agentsDir = package.directory.childDirectory('.agents')..createSync(); + final RepositoryPackage subPackage = createFakePackage('sub_package', agentsDir); + + expect(subPackage.isPubIgnored, true); + }); + + test( + 'returns true if the package is in an ignored directory (without trailing slash)', + () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('.agents'); + + final Directory agentsDir = package.directory.childDirectory('.agents')..createSync(); + final RepositoryPackage subPackage = createFakePackage('sub_package', agentsDir); + + expect(subPackage.isPubIgnored, true); + }, + ); + + test('returns true if a deeply nested package is in an ignored directory', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('.agents/'); + + final Directory nestedDir = + package.directory.childDirectory('.agents').childDirectory('skills') + ..createSync(recursive: true); + final RepositoryPackage subPackage = createFakePackage('sub_package', nestedDir); + + expect(subPackage.isPubIgnored, true); + }); + + test('returns false if the package is not in an ignored directory', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('.agents/'); + + final Directory otherDir = package.directory.childDirectory('other')..createSync(); + final RepositoryPackage subPackage = createFakePackage('sub_package', otherDir); + + expect(subPackage.isPubIgnored, false); + }); + + test('ignores comments and empty lines in .pubignore', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('\n# a comment\n.agents/\n'); + + final Directory agentsDir = package.directory.childDirectory('.agents')..createSync(); + final RepositoryPackage subPackage = createFakePackage('sub_package', agentsDir); + + expect(subPackage.isPubIgnored, true); + }); + + test('respects anchored patterns', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('/.agents/'); + + final Directory agentsDir = package.directory.childDirectory('.agents')..createSync(); + final RepositoryPackage subPackage1 = createFakePackage('sub_package1', agentsDir); + + final Directory nestedAgentsDir = + package.directory.childDirectory('other').childDirectory('.agents') + ..createSync(recursive: true); + final RepositoryPackage subPackage2 = createFakePackage('sub_package2', nestedAgentsDir); + + expect(subPackage1.isPubIgnored, true); + expect(subPackage2.isPubIgnored, false); + }); + + test('handles trailing slashes correctly', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('foo/'); + + final Directory fooDir = package.directory.childDirectory('foo')..createSync(); + final RepositoryPackage subPackage = createFakePackage('sub_package', fooDir); + + expect(subPackage.isPubIgnored, true); + }); + + test('gracefully ignores malformed glob patterns', () async { + final RepositoryPackage package = createFakePackage('a_package', packagesDir); + package.directory.childFile('.pubignore').writeAsStringSync('[unclosed bracket\n.agents/'); + + final Directory agentsDir = package.directory.childDirectory('.agents')..createSync(); + final RepositoryPackage subPackage = createFakePackage('sub_package', agentsDir); + + // Should not throw, and should still match valid patterns in the file + expect(subPackage.isPubIgnored, true); + }); + }); group('pubspec', () { test('file', () async { diff --git a/script/tool/test/validate_command_pubignore_test.dart b/script/tool/test/validate_command_pubignore_test.dart new file mode 100644 index 000000000000..0786184d09d3 --- /dev/null +++ b/script/tool/test/validate_command_pubignore_test.dart @@ -0,0 +1,62 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/validate_command.dart'; +import 'package:git/git.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'util.dart'; + +void main() { + group('test validate_command pubignore logic', () { + late CommandRunner runner; + late ValidateCommand command; + late Directory packagesDir; + + setUp(() { + final mockPlatform = MockPlatform(); + final ({ + Directory packagesDir, + RecordingProcessRunner processRunner, + RecordingProcessRunner gitProcessRunner, + GitDir gitDir, + }) + mocks = configureBaseCommandMocks(platform: mockPlatform); + packagesDir = mocks.packagesDir; + final Directory repoRoot = packagesDir.parent; + setToolConfig(repoRoot, minDartVersion: '1.0.0'); + + command = ValidateCommand( + packagesDir, + processRunner: mocks.processRunner, + platform: mockPlatform, + gitDir: mocks.gitDir, + targetedValidators: {Validator.pubspec}, + ); + runner = CommandRunner('validate_command', 'Test for validate_command'); + runner.addCommand(command); + }); + + test('skips packages ignored by .pubignore', () async { + final RepositoryPackage plugin = createFakePlugin('a_plugin', packagesDir); + plugin.directory.childFile('.pubignore').writeAsStringSync('ignored_dir/'); + + final Directory ignoredDir = plugin.directory.childDirectory('ignored_dir')..createSync(); + createFakePlugin('ignored_plugin', ignoredDir); + + final List output = await runCapturingPrint( + runner, + ['validate', '--packages', 'a_plugin'], + errorHandler: (Error e) { + // Swallow the ToolExit caused by a_plugin having an invalid pubspec + }, + ); + + expect(output, containsAllInOrder([contains('SKIPPING: Ignored by .pubignore')])); + }); + }); +}