Only exclude platform directories that actually exist in analysis_options.yaml (#191151)
`AnalysisOptionsMigration` wrote a fixed seven-entry `exclude` list
(`android/**`, `ios/**`, `web/**`, `windows/**`, `macos/**`, `linux/**`,
plus `build/**`) into `analysis_options.yaml` on every `flutter pub get`
/ `flutter analyze` / `flutter run` / `flutter build`, regardless of
what the project actually contains.
Two concrete problems from this:
- A Dart-only package (e.g. `dart create -t web`) that uses `web/` as a
real source directory gets it silently excluded from analysis the first
time any `flutter` command touches the package (this happens in mixed
Flutter/Dart monorepos and Dart workspaces).
- A Flutter app created with `flutter create --platforms=android,ios`
still gets exclusions for `web/`, `windows/`, `macos/`, and `linux/`,
which it doesn't have, and `ProjectMigrator` has no opt-out — every
command restores the full list.
Fix:
- `AnalysisOptionsMigration.migrate()` now skips entirely when the
package has no `flutter` dependency (i.e. it isn't a Flutter project at
all — the discriminator suggested in the issue).
- The exclude list is now built conditionally from
`project.<platform>.existsSync()` for each platform, so only directories
the project actually has as platform scaffolds are excluded. `build/**`
stays unconditional.
- `templates/app/analysis_options.yaml.tmpl` (used by `flutter create`)
gets the same per-platform conditionals via the existing mustache
context (`{{#android}}`, `{{#ios}}`, etc.), so newly created projects
don't get the fixed list either.
Fixes #191131
## Pre-launch Checklist
- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[AI contribution guidelines]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.mddiff --git a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart
index 35c0abb..2fb42b6 100644
--- a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart
+++ b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart
@@ -29,6 +29,14 @@
return;
}
+ // A pure Dart package (no `flutter` dependency) has no platform scaffold
+ // directories, so `web/`, `android/`, etc. are ordinary source or asset
+ // directories rather than generated platform code. Excluding them here
+ // would silently drop them from analysis.
+ if (!_project.manifest.dependencies.contains('flutter')) {
+ return;
+ }
+
final String originalContent = _analysisOptionsFile.readAsStringSync();
final YamlNode root;
try {
@@ -43,14 +51,14 @@
return;
}
- const excludesToExclude = <String>[
+ final excludesToExclude = <String>[
'build/**',
- 'android/**',
- 'ios/**',
- 'web/**',
- 'windows/**',
- 'macos/**',
- 'linux/**',
+ if (_project.android.existsSync()) 'android/**',
+ if (_project.ios.existsSync()) 'ios/**',
+ if (_project.web.existsSync()) 'web/**',
+ if (_project.windows.existsSync()) 'windows/**',
+ if (_project.macos.existsSync()) 'macos/**',
+ if (_project.linux.existsSync()) 'linux/**',
];
final Set<String> activeExcludes = await _collectExcludes(_analysisOptionsFile);
diff --git a/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl b/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl
index 775c7ce..86d64ab 100644
--- a/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl
+++ b/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl
@@ -14,12 +14,24 @@
analyzer:
exclude:
- build/**
+{{#android}}
- android/**
+{{/android}}
+{{#ios}}
- ios/**
+{{/ios}}
+{{#web}}
- web/**
+{{/web}}
+{{#windows}}
- windows/**
+{{/windows}}
+{{#macos}}
- macos/**
+{{/macos}}
+{{#linux}}
- linux/**
+{{/linux}}
{{^withEmptyMain}}
linter:
diff --git a/packages/flutter_tools/test/commands.shard/permeable/create_test.dart b/packages/flutter_tools/test/commands.shard/permeable/create_test.dart
index 8aced1d..abec23d 100644
--- a/packages/flutter_tools/test/commands.shard/permeable/create_test.dart
+++ b/packages/flutter_tools/test/commands.shard/permeable/create_test.dart
@@ -4209,6 +4209,23 @@
},
);
+ testUsingContext('analysis_options.yaml only excludes platforms that were generated', () async {
+ await _createProject(
+ projectDir,
+ <String>['--no-pub', '--platforms', 'android,ios'],
+ <String>['analysis_options.yaml'],
+ );
+
+ final String analysisOptions = projectDir.childFile('analysis_options.yaml').readAsStringSync();
+ expect(analysisOptions, contains('- build/**'));
+ expect(analysisOptions, contains('- android/**'));
+ expect(analysisOptions, contains('- ios/**'));
+ expect(analysisOptions, isNot(contains('- web/**')));
+ expect(analysisOptions, isNot(contains('- windows/**')));
+ expect(analysisOptions, isNot(contains('- macos/**')));
+ expect(analysisOptions, isNot(contains('- linux/**')));
+ });
+
testUsingContext('should escape ":" in project description', () async {
await _createProject(
projectDir,
diff --git a/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart b/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart
index 15fea38..f497703 100644
--- a/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart
+++ b/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart
@@ -13,6 +13,8 @@
import '../../src/common.dart';
+const _allPlatforms = <String>['android', 'ios', 'web', 'windows', 'macos', 'linux'];
+
void main() {
group('Analysis options migration', () {
testWithoutContext('skipped if analysis_options.yaml file is missing', () async {
@@ -161,6 +163,47 @@
expect(migratedContents, contains('- linux/**'));
});
+ testWithoutContext(
+ 'skipped entirely for a Dart-only package (no flutter dependency)',
+ () async {
+ final _TestContext context = _createTestContext(
+ isFlutterProject: false,
+ platforms: <String>['web'],
+ );
+ const analysisOptionsContents = '''
+include: package:lints/recommended.yaml
+''';
+ context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents);
+
+ final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger);
+ await migration.migrate();
+
+ expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents);
+ expect(context.testLogger.statusText, isEmpty);
+ },
+ );
+
+ testWithoutContext('only excludes platform directories that actually exist', () async {
+ final _TestContext context = _createTestContext(platforms: <String>['android', 'ios']);
+ const analysisOptionsContents = '''
+include: package:flutter_lints/flutter.yaml
+''';
+
+ context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents);
+
+ final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger);
+ await migration.migrate();
+
+ final String migratedContents = context.analysisOptionsFile.readAsStringSync();
+ expect(migratedContents, contains('- build/**'));
+ expect(migratedContents, contains('- android/**'));
+ expect(migratedContents, contains('- ios/**'));
+ expect(migratedContents, isNot(contains('- web/**')));
+ expect(migratedContents, isNot(contains('- windows/**')));
+ expect(migratedContents, isNot(contains('- macos/**')));
+ expect(migratedContents, isNot(contains('- linux/**')));
+ });
+
testWithoutContext('migrates and preserves comments inside exclude list', () async {
final _TestContext context = _createTestContext();
const analysisOptionsContents = '''
@@ -432,17 +475,63 @@
MemoryFileSystem memoryFileSystem,
File analysisOptionsFile,
BufferLogger testLogger,
- FakeFlutterProject mockProject,
+ FlutterProject mockProject,
});
-_TestContext _createTestContext() {
+/// Builds a test context backed by a real [FlutterProject] view of an
+/// in-memory directory, so that platform existence checks (`android.existsSync()`,
+/// etc.) reflect the directories actually created here, matching production
+/// behavior instead of being separately mocked.
+_TestContext _createTestContext({
+ bool isFlutterProject = true,
+ List<String> platforms = _allPlatforms,
+}) {
final memoryFileSystem = MemoryFileSystem.test();
- final File analysisOptionsFile = memoryFileSystem.file('analysis_options.yaml');
+ final Directory projectDirectory = memoryFileSystem.currentDirectory;
+ final File analysisOptionsFile = projectDirectory.childFile('analysis_options.yaml');
final testLogger = BufferLogger(
terminal: Terminal.test(),
outputPreferences: OutputPreferences.test(),
);
- final mockProject = FakeFlutterProject(directory: memoryFileSystem.currentDirectory);
+
+ projectDirectory
+ .childFile('pubspec.yaml')
+ .writeAsStringSync(
+ isFlutterProject
+ ? '''
+name: test_project
+dependencies:
+ flutter:
+ sdk: flutter
+'''
+ : '''
+name: test_project
+''',
+ );
+
+ if (platforms.contains('android')) {
+ projectDirectory.childDirectory('android').createSync(recursive: true);
+ }
+ if (platforms.contains('ios')) {
+ projectDirectory.childDirectory('ios').createSync(recursive: true);
+ }
+ if (platforms.contains('web')) {
+ projectDirectory.childDirectory('web').childFile('index.html').createSync(recursive: true);
+ }
+ if (platforms.contains('windows')) {
+ projectDirectory
+ .childDirectory('windows')
+ .childFile('CMakeLists.txt')
+ .createSync(recursive: true);
+ }
+ if (platforms.contains('macos')) {
+ projectDirectory.childDirectory('macos').createSync(recursive: true);
+ }
+ if (platforms.contains('linux')) {
+ projectDirectory.childDirectory('linux').createSync(recursive: true);
+ }
+
+ final FlutterProject mockProject = FlutterProject.fromDirectoryTest(projectDirectory);
return (
memoryFileSystem: memoryFileSystem,
analysisOptionsFile: analysisOptionsFile,
@@ -451,13 +540,6 @@
);
}
-class FakeFlutterProject extends Fake implements FlutterProject {
- FakeFlutterProject({required this.directory});
-
- @override
- final Directory directory;
-}
-
class FakePackageConfig extends Fake implements PackageConfig {
FakePackageConfig(this._packages);