diff --git a/.ci/targets/analyze.yaml b/.ci/targets/analyze.yaml index 3d4f4716cff3..9268ea2f127a 100644 --- a/.ci/targets/analyze.yaml +++ b/.ci/targets/analyze.yaml @@ -14,7 +14,7 @@ tasks: script: .ci/scripts/tool_runner.sh # DO NOT change the custom-analysis argument here without changing the Dart repo. # See the comment in script/configs/custom_analysis.yaml for details. - args: ["analyze", "--custom-analysis=script/configs/custom_analysis.yaml"] + args: ["analyze", "--custom-analysis=script/configs/custom_analysis.yaml", "--analyze-skills-for=script/configs/skills_analysis.yaml"] # Re-run analysis with path-based dependencies to ensure that publishing # the changes won't break analysis of other packages in the respository # that depend on it. 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..8ac0b11f2edb 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 ([bin/check.dart](bin/check.dart)) to perform the automated environment checks: ```bash -bash .agents/skills/check-readiness/scripts/check.sh +dart run .agents/skills/check-readiness/bin/check.dart ``` ### Handling the Results diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/bin/check.dart b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/bin/check.dart new file mode 100644 index 000000000000..4baca5071ab9 --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/bin/check.dart @@ -0,0 +1,17 @@ +// 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'; + +Future main(List args) async { + // Since this tool is executed via `dart run` from the package root, + // the current directory is the workspace root. + final String workspaceRoot = Directory.current.path; + + final checker = ReadinessChecker(); + final bool isReady = await checker.checkReadiness(workspaceRoot); + exitCode = isReady ? 0 : 1; +} 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..4fad392b264f --- /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:path/path.dart' as p; +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...'); + + if (!await _checkSymlinks(workspaceRoot)) { + return false; + } + if (!await _checkGitState(workspaceRoot)) { + return false; + } + if (!await _checkFlutterAndDart()) { + return false; + } + if (!await _checkDependencies(workspaceRoot)) { + return false; + } + + _log('Environment is fully ready!'); + return true; + } + + Future _checkSymlinks(String workspaceRoot) async { + _log('1. Checking skill symlinks...'); + final Directory agentsDir = _fileSystem.directory(p.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 (!_canRunCommand('flutter')) { + _log("Error: 'flutter' is not on the PATH."); + return false; + } + if (!_canRunCommand('dart')) { + _log("Error: 'dart' is not on the PATH."); + return false; + } + _log('Flutter and Dart are on the PATH.'); + return true; + } + + bool _canRunCommand(String command) { + // A simple check using ProcessManager's canRun + // NOTE: ProcessManager.canRun exists if we use process package > certain version + // Let's implement a safe check + return _processManager.canRun(command); + } + + 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..2146dcae0021 --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/pubspec.yaml @@ -0,0 +1,16 @@ +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 + +dev_dependencies: + build_runner: ^2.15.0 + mockito: ^5.7.0 + test: ^1.24.0 +dependencies: + file: ^7.0.1 + path: ^1.9.1 + process: ^5.0.5 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..7a4985fab3b6 --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart @@ -0,0 +1,144 @@ +// 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 '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'; + +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 = '/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(p.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(p.join(workspaceRoot, '.agents', 'skills')) + ..createSync(recursive: true); + + // MemoryFileSystem supports links + final Link link = fileSystem.link(p.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(p.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(p.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(p.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(p.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.')); + }); +} diff --git a/script/configs/skills_analysis.yaml b/script/configs/skills_analysis.yaml new file mode 100644 index 000000000000..058b0eb0d96d --- /dev/null +++ b/script/configs/skills_analysis.yaml @@ -0,0 +1 @@ +- camera_android_camerax diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 5fe1ee9e0929..7e7e448c5e15 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -38,6 +38,13 @@ class AnalyzeCommand extends PackageLoopingCommand { 'of allowed directories.', defaultsTo: [], ); + argParser.addMultiOption( + _analyzeSkillsFlag, + help: + 'A list of packages, or YAML files containing a list of packages, ' + 'that should have their .agents/skills and skills directories explicitly analyzed.', + defaultsTo: [], + ); argParser.addOption( _analysisSdk, valueHelp: 'dart-sdk', @@ -85,6 +92,7 @@ class AnalyzeCommand extends PackageLoopingCommand { static const String _dartFlag = 'dart'; static const String _customAnalysisFlag = 'custom-analysis'; + static const String _analyzeSkillsFlag = 'analyze-skills-for'; static const String _downgradeFlag = 'downgrade'; static const String _libOnlyFlag = 'lib-only'; static const String _analysisSdk = 'analysis-sdk'; @@ -95,6 +103,7 @@ class AnalyzeCommand extends PackageLoopingCommand { late String _dartBinaryPath; Set _allowedCustomAnalysisDirectories = const {}; + Set _packagesWithSkills = const {}; @override final String name = 'analyze'; @@ -170,6 +179,34 @@ class AnalyzeCommand extends PackageLoopingCommand { @override Future initializeRun() async { _allowedCustomAnalysisDirectories = getYamlListArg(_customAnalysisFlag); + _packagesWithSkills = getYamlListArg(_analyzeSkillsFlag); + + // Validate that all packages in _packagesWithSkills are valid packages. + final allPackages = {}; + for (final dir in [ + packagesDir, + if (thirdPartyPackagesDir.existsSync()) thirdPartyPackagesDir, + ]) { + for (final FileSystemEntity entity in dir.listSync(followLinks: false)) { + if (isPackage(entity)) { + allPackages.add(entity.basename); + } else if (entity is Directory) { + for (final FileSystemEntity subdir in entity.listSync(followLinks: false)) { + if (isPackage(subdir)) { + allPackages.add(subdir.basename); + } + } + } + } + } + + final Set invalidPackages = _packagesWithSkills.difference(allPackages); + if (invalidPackages.isNotEmpty) { + printError( + 'The following packages passed to --$_analyzeSkillsFlag are not valid packages: ${invalidPackages.join(', ')}', + ); + throw ToolExit(1); + } // Use the Dart SDK override if one was passed in. final dartSdk = argResults![_analysisSdk] as String?; @@ -305,17 +342,72 @@ class AnalyzeCommand extends PackageLoopingCommand { if (_hasUnexpectedAnalysisOptions(package)) { return PackageResult.fail(['Unexpected local analysis options']); } - final int exitCode = await processRunner.runAndStream(_dartBinaryPath, [ - 'analyze', - '--fatal-infos', - if (libOnly) 'lib', - ], workingDir: package.directory); + final analyzeArgs = ['analyze', '--fatal-infos']; + + if (libOnly) { + analyzeArgs.add('lib'); + } + + var analyzeAgentsSkills = false; + if (!libOnly && _packagesWithSkills.contains(package.directory.basename)) { + final List skillsErrors = _validateAgentsSkillsDirectory(package); + if (skillsErrors.isNotEmpty) { + return PackageResult.fail(skillsErrors); + } + analyzeAgentsSkills = true; + } + + int exitCode = await processRunner.runAndStream( + _dartBinaryPath, + analyzeArgs, + workingDir: package.directory, + ); + + if (analyzeAgentsSkills) { + final int skillsExitCode = await processRunner.runAndStream(_dartBinaryPath, [ + 'analyze', + '--fatal-infos', + '.agents/skills', + ], workingDir: package.directory); + if (skillsExitCode != 0) { + exitCode = skillsExitCode; + } + } + if (exitCode != 0) { return PackageResult.fail(); } 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/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..afed50f51d91 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; @@ -204,16 +206,74 @@ class RepositoryPackage { /// 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..db31e9e6d4ef 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -442,6 +442,91 @@ void main() { }); }); + group('--analyze-skills-for', () { + test('fails if an invalid package is provided', () async { + createFakePlugin('foo', packagesDir); + final File configFile = packagesDir.childFile('skills_config.yaml'); + configFile.writeAsStringSync('- non_existent_package'); + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['analyze', '--analyze-skills-for', configFile.path], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'The following packages passed to --analyze-skills-for are not valid packages: non_existent_package', + ), + ]), + ); + }); + + test('fails if configured package has no dart files in .agents/skills', () async { + createFakePlugin('foo', packagesDir); + final File configFile = packagesDir.childFile('skills_config.yaml'); + configFile.writeAsStringSync('- foo'); + + // Note: we purposely do not create any .dart files in .agents/skills + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['analyze', '--analyze-skills-for', configFile.path], + 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', () async { + final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); + final File configFile = packagesDir.childFile('skills_config.yaml'); + configFile.writeAsStringSync('- foo'); + + plugin.directory + .childDirectory('.agents') + .childDirectory('skills') + .childFile('test.dart') + .createSync(recursive: true); + + await runCapturingPrint(runner, [ + 'analyze', + '--analyze-skills-for', + configFile.path, + ]); + + 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('skips if requested if "pub get" fails in the resolver', () async { final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); 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')])); + }); + }); +}