diff --git a/_test_common/lib/test_phases.dart b/_test_common/lib/test_phases.dart index 52a648ce9..ef80e3c7a 100644 --- a/_test_common/lib/test_phases.dart +++ b/_test_common/lib/test_phases.dart @@ -19,7 +19,11 @@ Future wait(int milliseconds) => Future.delayed(Duration(milliseconds: milliseconds)); void _printOnFailure(LogRecord record) { - printOnFailure('$record'); + printOnFailure( + '$record' + '${record.error == null ? '' : ' ${record.error}'}' + '${record.stackTrace == null ? '' : ' ${record.stackTrace}'}', + ); } /// Runs [builders] in a test environment. diff --git a/build/CHANGELOG.md b/build/CHANGELOG.md index aaee03528..de612a562 100644 --- a/build/CHANGELOG.md +++ b/build/CHANGELOG.md @@ -15,6 +15,7 @@ - Refactor `FileBasedAssetReader` and `FileBasedAssetWriter` to `ReaderWriter`. - Move `BuildStepImpl` to `build_runner_core`, use `SingleStepReader` directly. - Add `LibraryCycleGraphLoader` for loading transitive deps for analysis. +- Track resolver dependencies as library cycle graphs. ## 2.4.2 diff --git a/build/lib/src/internal.dart b/build/lib/src/internal.dart index 18a9378f3..c55eda1d2 100644 --- a/build/lib/src/internal.dart +++ b/build/lib/src/internal.dart @@ -11,6 +11,7 @@ export 'library_cycle_graph/asset_deps_loader.dart'; export 'library_cycle_graph/library_cycle.dart'; export 'library_cycle_graph/library_cycle_graph.dart'; export 'library_cycle_graph/library_cycle_graph_loader.dart'; +export 'library_cycle_graph/phased_asset_deps.dart'; export 'library_cycle_graph/phased_reader.dart'; export 'library_cycle_graph/phased_value.dart'; export 'state/asset_finder.dart'; diff --git a/build/lib/src/library_cycle_graph/asset_deps.dart b/build/lib/src/library_cycle_graph/asset_deps.dart index 035a489b0..761785a2a 100644 --- a/build/lib/src/library_cycle_graph/asset_deps.dart +++ b/build/lib/src/library_cycle_graph/asset_deps.dart @@ -4,6 +4,7 @@ import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; import '../../build.dart' hide Builder; @@ -17,11 +18,15 @@ part 'asset_deps.g.dart'; /// Missing or not-yet-generated sources can be represented by this class: they /// have no deps. abstract class AssetDeps implements Built { + static Serializer get serializer => _$assetDepsSerializer; + static final AssetDeps empty = _$AssetDeps._(deps: BuiltSet()); BuiltSet get deps; factory AssetDeps(Iterable deps) => _$AssetDeps._(deps: BuiltSet.of(deps)); + factory AssetDeps.build(void Function(AssetDepsBuilder) updates) = + _$AssetDeps; AssetDeps._(); } diff --git a/build/lib/src/library_cycle_graph/asset_deps.g.dart b/build/lib/src/library_cycle_graph/asset_deps.g.dart index 49e909165..f951d4aea 100644 --- a/build/lib/src/library_cycle_graph/asset_deps.g.dart +++ b/build/lib/src/library_cycle_graph/asset_deps.g.dart @@ -6,6 +6,65 @@ part of 'asset_deps.dart'; // BuiltValueGenerator // ************************************************************************** +Serializer _$assetDepsSerializer = new _$AssetDepsSerializer(); + +class _$AssetDepsSerializer implements StructuredSerializer { + @override + final Iterable types = const [AssetDeps, _$AssetDeps]; + @override + final String wireName = 'AssetDeps'; + + @override + Iterable serialize( + Serializers serializers, + AssetDeps object, { + FullType specifiedType = FullType.unspecified, + }) { + final result = [ + 'deps', + serializers.serialize( + object.deps, + specifiedType: const FullType(BuiltSet, const [ + const FullType(AssetId), + ]), + ), + ]; + + return result; + } + + @override + AssetDeps deserialize( + Serializers serializers, + Iterable serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = new AssetDepsBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'deps': + result.deps.replace( + serializers.deserialize( + value, + specifiedType: const FullType(BuiltSet, const [ + const FullType(AssetId), + ]), + )! + as BuiltSet, + ); + break; + } + } + + return result.build(); + } +} + class _$AssetDeps extends AssetDeps { @override final BuiltSet deps; diff --git a/build/lib/src/library_cycle_graph/asset_deps_loader.dart b/build/lib/src/library_cycle_graph/asset_deps_loader.dart index 16f636d12..56cb8e32b 100644 --- a/build/lib/src/library_cycle_graph/asset_deps_loader.dart +++ b/build/lib/src/library_cycle_graph/asset_deps_loader.dart @@ -7,6 +7,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import '../asset/id.dart'; import 'asset_deps.dart'; +import 'phased_asset_deps.dart'; import 'phased_reader.dart'; import 'phased_value.dart'; @@ -17,6 +18,8 @@ class AssetDepsLoader { final PhasedReader _reader; AssetDepsLoader(this._reader); + factory AssetDepsLoader.fromDeps(PhasedAssetDeps deps) => + _InMemoryAssetDepsLoader(deps); /// The phase that this loader is reading build state at. int get phase => _reader.phase; @@ -57,3 +60,32 @@ class AssetDepsLoader { return result.build(); } } + +// An [AssetDepsLoader] from already-loaded asset deps. +class _InMemoryAssetDepsLoader implements AssetDepsLoader { + final Future> _empty = Future.value( + PhasedValue.fixed(AssetDeps.empty), + ); + PhasedAssetDeps phasedAssetDeps; + + _InMemoryAssetDepsLoader(this.phasedAssetDeps); + + // This is important: it prevents LibraryCycleGraphLoader from trying to load + // data that is not in an incomplete [phasedAssetDeps]. + @override + int get phase => phasedAssetDeps.phase; + + @override + ExpiringValue _parse(AssetId id, ExpiringValue content) => + throw UnimplementedError(); + + @override + PhasedReader get _reader => throw UnimplementedError(); + + @override + Future> load(AssetId id) { + var result = phasedAssetDeps.assetDeps[id]; + if (result == null) return _empty; + return Future.value(result); + } +} diff --git a/build/lib/src/library_cycle_graph/library_cycle.dart b/build/lib/src/library_cycle_graph/library_cycle.dart index 0b2432a9c..84c569f2f 100644 --- a/build/lib/src/library_cycle_graph/library_cycle.dart +++ b/build/lib/src/library_cycle_graph/library_cycle.dart @@ -19,4 +19,7 @@ abstract class LibraryCycle factory LibraryCycle([void Function(LibraryCycleBuilder) updates]) = _$LibraryCycle; LibraryCycle._(); + + factory LibraryCycle.of(Iterable ids) => + _$LibraryCycle._(ids: ids.toBuiltSet()); } diff --git a/build/lib/src/library_cycle_graph/library_cycle_graph.dart b/build/lib/src/library_cycle_graph/library_cycle_graph.dart index 61b05f675..3f9de08df 100644 --- a/build/lib/src/library_cycle_graph/library_cycle_graph.dart +++ b/build/lib/src/library_cycle_graph/library_cycle_graph.dart @@ -4,6 +4,7 @@ import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; import '../asset/id.dart'; import 'library_cycle.dart'; @@ -13,6 +14,8 @@ part 'library_cycle_graph.g.dart'; /// A directed acyclic graph of [LibraryCycle]s. abstract class LibraryCycleGraph implements Built { + static Serializer get serializer => + _$libraryCycleGraphSerializer; LibraryCycle get root; BuiltList get children; @@ -21,7 +24,7 @@ abstract class LibraryCycleGraph LibraryCycleGraph._(); /// All subgraphs in the graph, including the root. - Iterable get transitiveGraphs { + Iterable transitiveGraphs() { final result = Set.identity(); final nextGraphs = [this]; @@ -40,8 +43,8 @@ abstract class LibraryCycleGraph // graph rather than being expanded into an explicit set of nodes. So, remove // uses of this. If in the end it's still needed, investigate if it needs to // be optimized. - Iterable get transitiveDeps sync* { - for (final graph in transitiveGraphs) { + Iterable transitiveDeps() sync* { + for (final graph in transitiveGraphs()) { yield* graph.root.ids; } } diff --git a/build/lib/src/library_cycle_graph/library_cycle_graph.g.dart b/build/lib/src/library_cycle_graph/library_cycle_graph.g.dart index ab683aafc..4dd473963 100644 --- a/build/lib/src/library_cycle_graph/library_cycle_graph.g.dart +++ b/build/lib/src/library_cycle_graph/library_cycle_graph.g.dart @@ -6,6 +6,81 @@ part of 'library_cycle_graph.dart'; // BuiltValueGenerator // ************************************************************************** +Serializer _$libraryCycleGraphSerializer = + new _$LibraryCycleGraphSerializer(); + +class _$LibraryCycleGraphSerializer + implements StructuredSerializer { + @override + final Iterable types = const [LibraryCycleGraph, _$LibraryCycleGraph]; + @override + final String wireName = 'LibraryCycleGraph'; + + @override + Iterable serialize( + Serializers serializers, + LibraryCycleGraph object, { + FullType specifiedType = FullType.unspecified, + }) { + final result = [ + 'root', + serializers.serialize( + object.root, + specifiedType: const FullType(LibraryCycle), + ), + 'children', + serializers.serialize( + object.children, + specifiedType: const FullType(BuiltList, const [ + const FullType(LibraryCycleGraph), + ]), + ), + ]; + + return result; + } + + @override + LibraryCycleGraph deserialize( + Serializers serializers, + Iterable serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = new LibraryCycleGraphBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'root': + result.root.replace( + serializers.deserialize( + value, + specifiedType: const FullType(LibraryCycle), + )! + as LibraryCycle, + ); + break; + case 'children': + result.children.replace( + serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, const [ + const FullType(LibraryCycleGraph), + ]), + )! + as BuiltList, + ); + break; + } + } + + return result.build(); + } +} + class _$LibraryCycleGraph extends LibraryCycleGraph { @override final LibraryCycle root; diff --git a/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart b/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart index 089c54e49..f960ad60f 100644 --- a/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart +++ b/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart @@ -12,6 +12,7 @@ import 'asset_deps.dart'; import 'asset_deps_loader.dart'; import 'library_cycle.dart'; import 'library_cycle_graph.dart'; +import 'phased_asset_deps.dart'; import 'phased_value.dart'; /// Loads [LibraryCycleGraph]s during a phased build. @@ -482,9 +483,14 @@ class LibraryCycleGraphLoader { AssetId id, ) async { final graph = await libraryCycleGraphOf(assetDepsLoader, id); - return graph.valueAt(phase: assetDepsLoader.phase).transitiveDeps; + return graph.valueAt(phase: assetDepsLoader.phase).transitiveDeps(); } + /// Serializable data from which the library cycle graphs can be + /// reconstructed. + PhasedAssetDeps phasedAssetDeps() => + PhasedAssetDeps((b) => b.assetDeps.addAll(_assetDeps)); + @override String toString() => ''' LibraryCycleGraphLoader( diff --git a/build/lib/src/library_cycle_graph/phased_asset_deps.dart b/build/lib/src/library_cycle_graph/phased_asset_deps.dart new file mode 100644 index 000000000..56745b9cb --- /dev/null +++ b/build/lib/src/library_cycle_graph/phased_asset_deps.dart @@ -0,0 +1,60 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math'; + +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +import '../asset/id.dart'; +import 'asset_deps.dart'; +import 'phased_value.dart'; + +part 'phased_asset_deps.g.dart'; + +/// Serializable data from which library cycle graphs can be reconstructed. +/// +/// Pass to `AssetDepsLoader.fromDeps` then use that to create a +/// `LibraryCycleGraphLoader`. +abstract class PhasedAssetDeps + implements Built { + static Serializer get serializer => + _$phasedAssetDepsSerializer; + + BuiltMap> get assetDeps; + + factory PhasedAssetDeps([void Function(PhasedAssetDepsBuilder) b]) = + _$PhasedAssetDeps; + PhasedAssetDeps._(); + + factory PhasedAssetDeps.of(Map> assetDeps) => + _$PhasedAssetDeps._(assetDeps: assetDeps.build()); + + /// Returns this data with [other] added to it. + PhasedAssetDeps addAll(PhasedAssetDeps other) { + final result = toBuilder(); + for (final entry in other.assetDeps.entries) { + result.assetDeps[entry.key] = entry.value; + } + return result.build(); + } + + /// The max phase before there is any incomplete data, or 0xffffffff if there + /// is no incomplete data. + @memoized + int get phase { + int? result; + for (final entry in assetDeps.values) { + if (!entry.isComplete) { + if (result == null) { + result = entry.expiresAfter; + } else { + result = min(result, entry.expiresAfter!); + } + } + } + return result ?? 0xffffffff; + } +} diff --git a/build/lib/src/library_cycle_graph/phased_asset_deps.g.dart b/build/lib/src/library_cycle_graph/phased_asset_deps.g.dart new file mode 100644 index 000000000..16a210efb --- /dev/null +++ b/build/lib/src/library_cycle_graph/phased_asset_deps.g.dart @@ -0,0 +1,177 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'phased_asset_deps.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializer _$phasedAssetDepsSerializer = + new _$PhasedAssetDepsSerializer(); + +class _$PhasedAssetDepsSerializer + implements StructuredSerializer { + @override + final Iterable types = const [PhasedAssetDeps, _$PhasedAssetDeps]; + @override + final String wireName = 'PhasedAssetDeps'; + + @override + Iterable serialize( + Serializers serializers, + PhasedAssetDeps object, { + FullType specifiedType = FullType.unspecified, + }) { + final result = [ + 'assetDeps', + serializers.serialize( + object.assetDeps, + specifiedType: const FullType(BuiltMap, const [ + const FullType(AssetId), + const FullType(PhasedValue, const [const FullType(AssetDeps)]), + ]), + ), + ]; + + return result; + } + + @override + PhasedAssetDeps deserialize( + Serializers serializers, + Iterable serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = new PhasedAssetDepsBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'assetDeps': + result.assetDeps.replace( + serializers.deserialize( + value, + specifiedType: const FullType(BuiltMap, const [ + const FullType(AssetId), + const FullType(PhasedValue, const [const FullType(AssetDeps)]), + ]), + )!, + ); + break; + } + } + + return result.build(); + } +} + +class _$PhasedAssetDeps extends PhasedAssetDeps { + @override + final BuiltMap> assetDeps; + int? __phase; + + factory _$PhasedAssetDeps([void Function(PhasedAssetDepsBuilder)? updates]) => + (new PhasedAssetDepsBuilder()..update(updates))._build(); + + _$PhasedAssetDeps._({required this.assetDeps}) : super._() { + BuiltValueNullFieldError.checkNotNull( + assetDeps, + r'PhasedAssetDeps', + 'assetDeps', + ); + } + + @override + int get phase => __phase ??= super.phase; + + @override + PhasedAssetDeps rebuild(void Function(PhasedAssetDepsBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + PhasedAssetDepsBuilder toBuilder() => + new PhasedAssetDepsBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PhasedAssetDeps && assetDeps == other.assetDeps; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, assetDeps.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PhasedAssetDeps') + ..add('assetDeps', assetDeps)).toString(); + } +} + +class PhasedAssetDepsBuilder + implements Builder { + _$PhasedAssetDeps? _$v; + + MapBuilder>? _assetDeps; + MapBuilder> get assetDeps => + _$this._assetDeps ??= new MapBuilder>(); + set assetDeps(MapBuilder>? assetDeps) => + _$this._assetDeps = assetDeps; + + PhasedAssetDepsBuilder(); + + PhasedAssetDepsBuilder get _$this { + final $v = _$v; + if ($v != null) { + _assetDeps = $v.assetDeps.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(PhasedAssetDeps other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$PhasedAssetDeps; + } + + @override + void update(void Function(PhasedAssetDepsBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PhasedAssetDeps build() => _build(); + + _$PhasedAssetDeps _build() { + _$PhasedAssetDeps _$result; + try { + _$result = _$v ?? new _$PhasedAssetDeps._(assetDeps: assetDeps.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'assetDeps'; + assetDeps.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'PhasedAssetDeps', + _$failedField, + e.toString(), + ); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/build/lib/src/library_cycle_graph/phased_reader.dart b/build/lib/src/library_cycle_graph/phased_reader.dart index b3a2cdf65..b0986da7f 100644 --- a/build/lib/src/library_cycle_graph/phased_reader.dart +++ b/build/lib/src/library_cycle_graph/phased_reader.dart @@ -30,6 +30,10 @@ abstract class PhasedReader { /// empty string is returned for its content. Future> readPhased(AssetId id); + /// The contents at the current phase, or `null` if the file is missing at the + /// current phase. + Future readAtPhase(AssetId id); + /// Whether [id] is a generated asset that changes between [phase] and /// [comparedToPhase]. /// diff --git a/build/lib/src/library_cycle_graph/phased_value.dart b/build/lib/src/library_cycle_graph/phased_value.dart index c659c7b9a..5c0af2bc7 100644 --- a/build/lib/src/library_cycle_graph/phased_value.dart +++ b/build/lib/src/library_cycle_graph/phased_value.dart @@ -6,6 +6,7 @@ import 'dart:math'; import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; part 'phased_value.g.dart'; @@ -44,6 +45,8 @@ part 'phased_value.g.dart'; /// cases, fixed or changing exactly once, as different implementation types. abstract class PhasedValue implements Built, PhasedValueBuilder> { + static Serializer get serializer => _$phasedValueSerializer; + BuiltList> get values; factory PhasedValue([void Function(PhasedValueBuilder)? updates]) = @@ -153,6 +156,8 @@ abstract class PhasedValue /// value in the next phase. abstract class ExpiringValue implements Built, ExpiringValueBuilder> { + static Serializer get serializer => _$expiringValueSerializer; + T get value; int? get expiresAfter; diff --git a/build/lib/src/library_cycle_graph/phased_value.g.dart b/build/lib/src/library_cycle_graph/phased_value.g.dart index 66c844c5d..8e8b20814 100644 --- a/build/lib/src/library_cycle_graph/phased_value.g.dart +++ b/build/lib/src/library_cycle_graph/phased_value.g.dart @@ -6,6 +6,160 @@ part of 'phased_value.dart'; // BuiltValueGenerator // ************************************************************************** +Serializer> _$phasedValueSerializer = + new _$PhasedValueSerializer(); +Serializer> _$expiringValueSerializer = + new _$ExpiringValueSerializer(); + +class _$PhasedValueSerializer + implements StructuredSerializer> { + @override + final Iterable types = const [PhasedValue, _$PhasedValue]; + @override + final String wireName = 'PhasedValue'; + + @override + Iterable serialize( + Serializers serializers, + PhasedValue object, { + FullType specifiedType = FullType.unspecified, + }) { + final isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + if (!isUnderspecified) serializers.expectBuilder(specifiedType); + final parameterT = + isUnderspecified ? FullType.object : specifiedType.parameters[0]; + + final result = [ + 'values', + serializers.serialize( + object.values, + specifiedType: new FullType(BuiltList, [ + new FullType(ExpiringValue, [parameterT]), + ]), + ), + ]; + + return result; + } + + @override + PhasedValue deserialize( + Serializers serializers, + Iterable serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + if (!isUnderspecified) serializers.expectBuilder(specifiedType); + final parameterT = + isUnderspecified ? FullType.object : specifiedType.parameters[0]; + + final result = + isUnderspecified + ? new PhasedValueBuilder() + : serializers.newBuilder(specifiedType) + as PhasedValueBuilder; + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'values': + result.values.replace( + serializers.deserialize( + value, + specifiedType: new FullType(BuiltList, [ + new FullType(ExpiringValue, [parameterT]), + ]), + )! + as BuiltList, + ); + break; + } + } + + return result.build(); + } +} + +class _$ExpiringValueSerializer + implements StructuredSerializer> { + @override + final Iterable types = const [ExpiringValue, _$ExpiringValue]; + @override + final String wireName = 'ExpiringValue'; + + @override + Iterable serialize( + Serializers serializers, + ExpiringValue object, { + FullType specifiedType = FullType.unspecified, + }) { + final isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + if (!isUnderspecified) serializers.expectBuilder(specifiedType); + final parameterT = + isUnderspecified ? FullType.object : specifiedType.parameters[0]; + + final result = [ + 'value', + serializers.serialize(object.value, specifiedType: parameterT), + ]; + Object? value; + value = object.expiresAfter; + if (value != null) { + result + ..add('expiresAfter') + ..add(serializers.serialize(value, specifiedType: const FullType(int))); + } + return result; + } + + @override + ExpiringValue deserialize( + Serializers serializers, + Iterable serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + if (!isUnderspecified) serializers.expectBuilder(specifiedType); + final parameterT = + isUnderspecified ? FullType.object : specifiedType.parameters[0]; + + final result = + isUnderspecified + ? new ExpiringValueBuilder() + : serializers.newBuilder(specifiedType) + as ExpiringValueBuilder; + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'value': + result.value = serializers.deserialize( + value, + specifiedType: parameterT, + ); + break; + case 'expiresAfter': + result.expiresAfter = + serializers.deserialize(value, specifiedType: const FullType(int)) + as int?; + break; + } + } + + return result.build(); + } +} + class _$PhasedValue extends PhasedValue { @override final BuiltList> values; diff --git a/build/test/library_cycle_graph/library_cycle_graph_loader_test.dart b/build/test/library_cycle_graph/library_cycle_graph_loader_test.dart index 33a47edde..61ba803ab 100644 --- a/build/test/library_cycle_graph/library_cycle_graph_loader_test.dart +++ b/build/test/library_cycle_graph/library_cycle_graph_loader_test.dart @@ -427,7 +427,7 @@ void main() { for (final phase in [1, 2, 3, 4, 5, 6]) { allCycles.add(phasedCycle.valueAt(phase: phase)); final graph = phasedGraph.valueAt(phase: phase); - allGraphs.addAll(graph.transitiveGraphs); + allGraphs.add(graph); } } diff --git a/build_resolvers/CHANGELOG.md b/build_resolvers/CHANGELOG.md index cf614987d..b1c2c976f 100644 --- a/build_resolvers/CHANGELOG.md +++ b/build_resolvers/CHANGELOG.md @@ -12,6 +12,7 @@ - Use `LibraryCycleGraphLoader` to load transitive deps for analysis. - Bug fix: fix delay on shutdown for fast builds when the "analyzer out of date" warning is displayed. +- Track resolver dependencies as library cycle graphs. ## 2.4.4 diff --git a/build_resolvers/lib/src/analysis_driver_model.dart b/build_resolvers/lib/src/analysis_driver_model.dart index ee2704a5c..05834e79d 100644 --- a/build_resolvers/lib/src/analysis_driver_model.dart +++ b/build_resolvers/lib/src/analysis_driver_model.dart @@ -24,10 +24,6 @@ import 'analysis_driver_filesystem.dart'; /// - Maintains an in-memory filesystem that is the analyzer's view of the /// build. /// - Notifies the analyzer of changes to that in-memory filesystem. -/// -/// TODO(davidmorgan): the implementation here is unfinished and not used -/// anywhere; finish it. See `build_asset_uri_resolver.dart` for the current -/// implementation. class AnalysisDriverModel { /// The instance used by the shared `AnalyzerResolvers` instance. static AnalysisDriverModel sharedInstance = AnalysisDriverModel(); @@ -55,6 +51,13 @@ class AnalysisDriverModel { _syncedOntoFilesystemAtPhase.clear(); } + /// Serializable data from which the library cycle graphs can be + /// reconstructed. + /// + /// This must be used to associated the entrypoints recorded with + /// `InputTracker.addResolverEntrypoint` to transitively loaded assets. + PhasedAssetDeps phasedAssetDeps() => _graphLoader.phasedAssetDeps(); + /// Attempts to parse [uri] into an [AssetId] and returns it if it is cached. /// /// Handles 'package:' or 'asset:' URIs, as well as 'file:' URIs of the form @@ -108,27 +111,24 @@ class AnalysisDriverModel { withDriverResource, { required bool transitive, }) async { - Iterable idsToSyncOntoFilesystem = [entrypoint]; - Iterable inputIds = [entrypoint]; + Iterable idsToSyncOntoFilesystem; // If requested, find transitive imports. if (transitive) { // Note: `transitiveDepsOf` can cause loads that cause builds that cause a // recursive `_performResolve` on this same `AnalysisDriver` instance. final nodeLoader = AssetDepsLoader(buildStep.phasedReader); + buildStep.inputTracker.addResolverEntrypoint(entrypoint); idsToSyncOntoFilesystem = await _graphLoader.transitiveDepsOf( nodeLoader, entrypoint, ); - inputIds = idsToSyncOntoFilesystem; + } else { + // Notify [buildStep] of its inputs. + buildStep.inputTracker.add(entrypoint); + idsToSyncOntoFilesystem = [entrypoint]; } - // Notify [buildStep] of its inputs. - buildStep.inputTracker.addAll( - primaryInput: buildStep.inputId, - inputs: inputIds, - ); - await withDriverResource((driver) async { // Sync changes onto the "URI resolver", the in-memory filesystem. final phase = buildStep.phasedReader.phase; @@ -149,10 +149,11 @@ class AnalysisDriverModel { } _syncedOntoFilesystemAtPhase[id] = phase; - final content = - await buildStep.canRead(id) - ? await buildStep.readAsString(id) - : null; + + // Tracking has already been done by calling `inputTracker` directly. + // Use `phasedReader` for the read instead of the `buildStep` methods + // `canRead` and `readAsString`, which would call `inputTracker`. + final content = await buildStep.phasedReader.readAtPhase(id); if (content == null) { filesystem.deleteFile(id.asPath); } else { diff --git a/build_resolvers/lib/src/internal.dart b/build_resolvers/lib/src/internal.dart new file mode 100644 index 000000000..dde143677 --- /dev/null +++ b/build_resolvers/lib/src/internal.dart @@ -0,0 +1,8 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Internal build state for `build_runner_core` only. +library; + +export 'analysis_driver_model.dart'; diff --git a/build_resolvers/test/resolver_test.dart b/build_resolvers/test/resolver_test.dart index 8838d9793..c8c3cd2bd 100644 --- a/build_resolvers/test/resolver_test.dart +++ b/build_resolvers/test/resolver_test.dart @@ -139,10 +139,8 @@ void runTests(ResolversFactory resolversFactory) { await resolver.libraryFor(entryPoint); }, assetReaderChecks: (reader) { - expect(reader.testing.inputsTracked, { + expect(reader.testing.resolverEntrypointsTracked, { AssetId('a', 'web/main.dart'), - AssetId('a', 'web/a.dart'), - AssetId('a', 'web/b.dart'), }); }, resolvers: createResolvers(), diff --git a/build_runner_core/CHANGELOG.md b/build_runner_core/CHANGELOG.md index f8a361b77..dbae4e680 100644 --- a/build_runner_core/CHANGELOG.md +++ b/build_runner_core/CHANGELOG.md @@ -34,6 +34,7 @@ in the asset graph. - Refactor invalidation to track current build progress in `Build` instead of in the asset graph. +- Track resolver dependencies as library cycle graphs. ## 8.0.0 diff --git a/build_runner_core/lib/src/asset_graph/graph.dart b/build_runner_core/lib/src/asset_graph/graph.dart index 57a8632b7..faa012f2c 100644 --- a/build_runner_core/lib/src/asset_graph/graph.dart +++ b/build_runner_core/lib/src/asset_graph/graph.dart @@ -74,6 +74,10 @@ class AssetGraph implements GeneratedAssetHider { /// Digests from the current build's [BuildPhases]. BuiltList postBuildActionsOptionsDigests; + /// Imports of resolved assets in the previous build, or `null` if this is a + /// clean build. + PhasedAssetDeps? previousPhasedAssetDeps; + AssetGraph._( BuildPhases buildPhases, this.dartVersion, diff --git a/build_runner_core/lib/src/asset_graph/node.dart b/build_runner_core/lib/src/asset_graph/node.dart index c3097cf5a..9283f896d 100644 --- a/build_runner_core/lib/src/asset_graph/node.dart +++ b/build_runner_core/lib/src/asset_graph/node.dart @@ -274,6 +274,9 @@ abstract class GeneratedNodeState /// to generate it. BuiltSet get inputs; + /// Entrypoints used for resolution with the analyzer. + BuiltSet get resolverEntrypoints; + /// Whether the generation succeded, or `null` if it did not run. /// /// A full build can complete with `null` results if there are optional diff --git a/build_runner_core/lib/src/asset_graph/node.g.dart b/build_runner_core/lib/src/asset_graph/node.g.dart index 894e875d0..41c4eb6b4 100644 --- a/build_runner_core/lib/src/asset_graph/node.g.dart +++ b/build_runner_core/lib/src/asset_graph/node.g.dart @@ -372,6 +372,13 @@ class _$GeneratedNodeStateSerializer const FullType(AssetId), ]), ), + 'resolverEntrypoints', + serializers.serialize( + object.resolverEntrypoints, + specifiedType: const FullType(BuiltSet, const [ + const FullType(AssetId), + ]), + ), ]; Object? value; value = object.result; @@ -410,6 +417,17 @@ class _$GeneratedNodeStateSerializer as BuiltSet, ); break; + case 'resolverEntrypoints': + result.resolverEntrypoints.replace( + serializers.deserialize( + value, + specifiedType: const FullType(BuiltSet, const [ + const FullType(AssetId), + ]), + )! + as BuiltSet, + ); + break; case 'result': result.result = serializers.deserialize( @@ -947,18 +965,29 @@ class _$GeneratedNodeState extends GeneratedNodeState { @override final BuiltSet inputs; @override + final BuiltSet resolverEntrypoints; + @override final bool? result; factory _$GeneratedNodeState([ void Function(GeneratedNodeStateBuilder)? updates, ]) => (new GeneratedNodeStateBuilder()..update(updates))._build(); - _$GeneratedNodeState._({required this.inputs, this.result}) : super._() { + _$GeneratedNodeState._({ + required this.inputs, + required this.resolverEntrypoints, + this.result, + }) : super._() { BuiltValueNullFieldError.checkNotNull( inputs, r'GeneratedNodeState', 'inputs', ); + BuiltValueNullFieldError.checkNotNull( + resolverEntrypoints, + r'GeneratedNodeState', + 'resolverEntrypoints', + ); } @override @@ -975,6 +1004,7 @@ class _$GeneratedNodeState extends GeneratedNodeState { if (identical(other, this)) return true; return other is GeneratedNodeState && inputs == other.inputs && + resolverEntrypoints == other.resolverEntrypoints && result == other.result; } @@ -982,6 +1012,7 @@ class _$GeneratedNodeState extends GeneratedNodeState { int get hashCode { var _$hash = 0; _$hash = $jc(_$hash, inputs.hashCode); + _$hash = $jc(_$hash, resolverEntrypoints.hashCode); _$hash = $jc(_$hash, result.hashCode); _$hash = $jf(_$hash); return _$hash; @@ -991,6 +1022,7 @@ class _$GeneratedNodeState extends GeneratedNodeState { String toString() { return (newBuiltValueToStringHelper(r'GeneratedNodeState') ..add('inputs', inputs) + ..add('resolverEntrypoints', resolverEntrypoints) ..add('result', result)) .toString(); } @@ -1005,6 +1037,12 @@ class GeneratedNodeStateBuilder _$this._inputs ??= new SetBuilder(); set inputs(SetBuilder? inputs) => _$this._inputs = inputs; + SetBuilder? _resolverEntrypoints; + SetBuilder get resolverEntrypoints => + _$this._resolverEntrypoints ??= new SetBuilder(); + set resolverEntrypoints(SetBuilder? resolverEntrypoints) => + _$this._resolverEntrypoints = resolverEntrypoints; + bool? _result; bool? get result => _$this._result; set result(bool? result) => _$this._result = result; @@ -1015,6 +1053,7 @@ class GeneratedNodeStateBuilder final $v = _$v; if ($v != null) { _inputs = $v.inputs.toBuilder(); + _resolverEntrypoints = $v.resolverEntrypoints.toBuilder(); _result = $v.result; _$v = null; } @@ -1040,12 +1079,18 @@ class GeneratedNodeStateBuilder try { _$result = _$v ?? - new _$GeneratedNodeState._(inputs: inputs.build(), result: result); + new _$GeneratedNodeState._( + inputs: inputs.build(), + resolverEntrypoints: resolverEntrypoints.build(), + result: result, + ); } catch (_) { late String _$failedField; try { _$failedField = 'inputs'; inputs.build(); + _$failedField = 'resolverEntrypoints'; + resolverEntrypoints.build(); } catch (e) { throw new BuiltValueNestedFieldError( r'GeneratedNodeState', diff --git a/build_runner_core/lib/src/asset_graph/serialization.dart b/build_runner_core/lib/src/asset_graph/serialization.dart index 704b26216..ef374e72e 100644 --- a/build_runner_core/lib/src/asset_graph/serialization.dart +++ b/build_runner_core/lib/src/asset_graph/serialization.dart @@ -8,7 +8,7 @@ part of 'graph.dart'; /// /// This should be incremented any time the serialize/deserialize formats /// change. -const _version = 29; +const _version = 30; /// Deserializes an [AssetGraph] from a [Map]. AssetGraph deserializeAssetGraph(List bytes) { @@ -72,6 +72,11 @@ AssetGraph deserializeAssetGraph(List bytes) { } } + graph.previousPhasedAssetDeps = serializers.deserializeWith( + PhasedAssetDeps.serializer, + serializedGraph['phasedAssetDeps'], + ); + identityAssetIdSerializer.reset(); return graph; } @@ -87,6 +92,10 @@ List serializeAssetGraph(AssetGraph graph) { final nodes = graph.allNodes .map((node) => serializers.serializeWith(AssetNode.serializer, node)) .toList(growable: false); + final serializedPhasedAssetDeps = serializers.serializeWith( + PhasedAssetDeps.serializer, + graph.previousPhasedAssetDeps, + ); var result = { 'version': _version, @@ -111,6 +120,7 @@ List serializeAssetGraph(AssetGraph graph) { graph.postBuildActionsOptionsDigests, specifiedType: const FullType(BuiltList, [FullType(Digest)]), ), + 'phasedAssetDeps': serializedPhasedAssetDeps, }; identityAssetIdSerializer.reset(); diff --git a/build_runner_core/lib/src/asset_graph/serializers.dart b/build_runner_core/lib/src/asset_graph/serializers.dart index 5fa01cd8b..682147bee 100644 --- a/build_runner_core/lib/src/asset_graph/serializers.dart +++ b/build_runner_core/lib/src/asset_graph/serializers.dart @@ -5,6 +5,8 @@ import 'dart:convert'; import 'package:build/build.dart' show AssetId, PostProcessBuildStep; +// ignore: implementation_imports +import 'package:build/src/internal.dart'; import 'package:built_collection/built_collection.dart'; import 'package:built_value/serializer.dart'; import 'package:crypto/crypto.dart'; @@ -30,7 +32,7 @@ final identityAssetIdSerializer = IdentitySerializer( assetIdSerializer, ); -@SerializersFor([AssetNode]) +@SerializersFor([AssetNode, PhasedAssetDeps, AssetDeps]) final Serializers serializers = (_$serializers.toBuilder() ..add(identityAssetIdSerializer) @@ -52,6 +54,20 @@ final Serializers serializers = ..addBuilderFactory( const FullType(BuiltList, [FullType(Digest)]), ListBuilder.new, + ) + ..addBuilderFactory( + const FullType(PhasedValue, [FullType(AssetDeps)]), + PhasedValueBuilder.new, + ) + ..addBuilderFactory( + const FullType(ExpiringValue, [FullType(AssetDeps)]), + ExpiringValueBuilder.new, + ) + ..addBuilderFactory( + const FullType(BuiltList, [ + FullType(ExpiringValue, [FullType(AssetDeps)]), + ]), + ListBuilder>.new, )) .build(); diff --git a/build_runner_core/lib/src/asset_graph/serializers.g.dart b/build_runner_core/lib/src/asset_graph/serializers.g.dart index 955b83b21..681bc19b4 100644 --- a/build_runner_core/lib/src/asset_graph/serializers.g.dart +++ b/build_runner_core/lib/src/asset_graph/serializers.g.dart @@ -8,13 +8,24 @@ part of 'serializers.dart'; Serializers _$serializers = (new Serializers().toBuilder() + ..add(AssetDeps.serializer) ..add(AssetNode.serializer) + ..add(ExpiringValue.serializer) ..add(GeneratedNodeConfiguration.serializer) ..add(GeneratedNodeState.serializer) ..add(GlobNodeConfiguration.serializer) ..add(GlobNodeState.serializer) ..add(NodeType.serializer) + ..add(PhasedAssetDeps.serializer) + ..add(PhasedValue.serializer) ..add(PostProcessBuildStepId.serializer) + ..addBuilderFactory( + const FullType(BuiltMap, const [ + const FullType(AssetId), + const FullType(PhasedValue, const [const FullType(AssetDeps)]), + ]), + () => new MapBuilder>(), + ) ..addBuilderFactory( const FullType(BuiltSet, const [const FullType(AssetId)]), () => new SetBuilder(), @@ -31,6 +42,14 @@ Serializers _$serializers = const FullType(BuiltSet, const [const FullType(AssetId)]), () => new SetBuilder(), ) + ..addBuilderFactory( + const FullType(BuiltSet, const [const FullType(AssetId)]), + () => new SetBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltSet, const [const FullType(AssetId)]), + () => new SetBuilder(), + ) ..addBuilderFactory( const FullType(BuiltSet, const [ const FullType(PostProcessBuildStepId), diff --git a/build_runner_core/lib/src/generate/build.dart b/build_runner_core/lib/src/generate/build.dart index eaa325634..5d3f87cf0 100644 --- a/build_runner_core/lib/src/generate/build.dart +++ b/build_runner_core/lib/src/generate/build.dart @@ -9,6 +9,8 @@ import 'dart:convert'; import 'package:build/build.dart'; // ignore: implementation_imports import 'package:build/src/internal.dart'; +// ignore: implementation_imports +import 'package:build_resolvers/src/internal.dart'; import 'package:crypto/crypto.dart'; import 'package:glob/glob.dart'; import 'package:logging/logging.dart'; @@ -56,6 +58,9 @@ class Build { final ResourceManager resourceManager; final AssetReaderWriter readerWriter; final RunnerAssetWriter deleteWriter; + final LibraryCycleGraphLoader previousLibraryCycleGraphLoader = + LibraryCycleGraphLoader(); + final AssetDepsLoader? previousDepsLoader; // Logging. final LogRenderer renderer; @@ -109,6 +114,10 @@ class Build { /// checked against the digest from the previous build. final Set changedOutputs = {}; + /// Whether a graph from [previousLibraryCycleGraphLoader] has any changed + /// transitive source. + final Map changedGraphs = Map.identity(); + Build({ required this.environment, required this.options, @@ -124,7 +133,11 @@ class Build { options.trackPerformance ? BuildPerformanceTracker() : BuildPerformanceTracker.noOp(), - logFine = _logger.level <= Level.FINE { + logFine = _logger.level <= Level.FINE, + previousDepsLoader = + assetGraph.previousPhasedAssetDeps == null + ? null + : AssetDepsLoader.fromDeps(assetGraph.previousPhasedAssetDeps!) { hungActionsHeartbeat = HungActionsHeartbeat(() { final message = StringBuffer(); const actionsToLogMax = 5; @@ -250,6 +263,13 @@ class Build { _logger, 'Caching finalized dependency graph', () async { + final updatedPhasedAssetDeps = + assetGraph.previousPhasedAssetDeps == null + ? AnalysisDriverModel.sharedInstance.phasedAssetDeps() + : assetGraph.previousPhasedAssetDeps!.addAll( + AnalysisDriverModel.sharedInstance.phasedAssetDeps(), + ); + assetGraph.previousPhasedAssetDeps = updatedPhasedAssetDeps; await readerWriter.writeAsBytes( AssetId(options.packageGraph.root.name, assetGraphPath), assetGraph.serialize(), @@ -845,60 +865,59 @@ class Build { // Check for changes to any inputs. final inputs = firstOutputState.inputs; for (final input in inputs) { - final inputNode = assetGraph.get(input)!; - if (inputNode.type == NodeType.generated) { - if (inputNode.generatedNodeConfiguration!.phaseNumber >= phaseNumber) { - // It's not readable in this phase. - continue; - } - // Ensure that the input was built, so [changedOutputs] is updated. - if (!processedOutputs.contains(input)) { - await _buildOutput(inputNode.id); - } - if (changedOutputs.contains(input)) { - if (logFine) { - _logger.fine( - 'Build ${renderer.build(primaryInput, outputs)} because ' - '${renderer.id(input)} was built and changed.', - ); - } - return true; - } - } else if (inputNode.type == NodeType.glob) { - // Ensure that the glob was evaluated, so [changedOutputs] is updated. - if (!processedOutputs.contains(input)) { - await _buildGlobNode(input); - } - if (changedOutputs.contains(input)) { - if (logFine) { - _logger.fine( - 'Build ${renderer.build(primaryInput, outputs)} because ' - '${inputNode.globNodeConfiguration!.glob} matches changed.', - ); - } - return true; - } - } else if (inputNode.type == NodeType.source) { - if (changedInputs.contains(input)) { - if (logFine) { - _logger.fine( - 'Build ${renderer.build(primaryInput, outputs)} because ' - '${renderer.id(input)} changed.', - ); + final changed = await _hasInputChanged( + phaseNumber: phaseNumber, + input: input, + ); + + if (changed) { + if (logFine) { + final inputNode = assetGraph.get(input)!; + switch (inputNode.type) { + case NodeType.generated: + _logger.fine( + 'Build ${renderer.build(primaryInput, outputs)} because ' + '${renderer.id(input)} was built and changed.', + ); + + case NodeType.glob: + _logger.fine( + 'Build ${renderer.build(primaryInput, outputs)} because ' + '${inputNode.globNodeConfiguration!.glob} matches changed.', + ); + + case NodeType.source: + _logger.fine( + 'Build ${renderer.build(primaryInput, outputs)} because ' + '${renderer.id(input)} changed.', + ); + + case NodeType.missingSource: + _logger.fine( + 'Build ${renderer.build(primaryInput, outputs)} because ' + '${renderer.id(input)} was deleted.', + ); + + default: + throw StateError(inputNode.type.toString()); } - return true; } - } else if (inputNode.type == NodeType.missingSource) { - // It's only a newly-deleted asset if it's also in [deletedAssets]. - if (deletedAssets.contains(input)) { - if (logFine) { - _logger.fine( - 'Build ${renderer.build(primaryInput, outputs)} because ' - '${renderer.id(input)} was deleted.', - ); - } - return true; + return true; + } + } + + for (final graphId in firstOutputState.resolverEntrypoints) { + if (await _hasInputGraphChanged( + phaseNumber: phaseNumber, + entrypointId: graphId, + )) { + if (logFine) { + _logger.fine( + 'Build ${renderer.build(primaryInput, outputs)} because ' + 'resolved source changed.', + ); } + return true; } } @@ -910,6 +929,126 @@ class Build { return false; } + /// Whether any source in the _previous build_ transitive import graph + /// of [entrypointId] has a change visible at [phaseNumber]. + /// + /// There is a tradeoff between returning early when a first change is + /// encountered and continuing to process the graph to produce results that + /// might be useful later. This implementation is eager, it computes whether + /// every subgraph reachable from [entrypointId] has changed. + Future _hasInputGraphChanged({ + required AssetId entrypointId, + required int phaseNumber, + }) async { + // If the result has already been calculated, return it. + final entrypointGraph = (await previousLibraryCycleGraphLoader + .libraryCycleGraphOf( + previousDepsLoader!, + entrypointId, + )).valueAt(phase: phaseNumber); + final maybeResult = changedGraphs[entrypointGraph]; + if (maybeResult != null) { + return maybeResult; + } + + final graphsToCheckStack = [entrypointGraph]; + + while (graphsToCheckStack.isNotEmpty) { + final nextGraph = graphsToCheckStack.last; + + // If there are multiple paths to a node, it might have been calculated + // for another path. + if (changedGraphs.containsKey(nextGraph)) { + graphsToCheckStack.removeLast(); + continue; + } + + // Determine whether there are child graphs not yet evaluated. + // + // If so, add them to the stack and "continue" to evaluate those before + // returning to this graph. + final childGraphsWithWorkToDo = []; + for (final childGraph in nextGraph.children) { + final maybeChildResult = changedGraphs[childGraph]; + if (maybeChildResult == null) { + childGraphsWithWorkToDo.add(childGraph); + } + } + if (childGraphsWithWorkToDo.isNotEmpty) { + graphsToCheckStack.addAll(childGraphsWithWorkToDo); + continue; + } + + // Determine whether the graph root library cycle has any changed IDs. If + // so, the graph has changed; if not, check whether any child graph + // changed. + var rootLibraryCycleHasChanged = false; + for (final id in nextGraph.root.ids) { + if (await _hasInputChanged(phaseNumber: phaseNumber, input: id)) { + rootLibraryCycleHasChanged = true; + break; + } + } + if (rootLibraryCycleHasChanged) { + changedGraphs[nextGraph] = true; + } else { + var anyChildHasChanged = false; + for (final childGraph in nextGraph.children) { + final childResult = changedGraphs[childGraph]; + if (childResult == null) { + throw StateError('Child graphs should have been checked.'); + } else if (childResult) { + anyChildHasChanged = true; + break; + } + } + changedGraphs[nextGraph] = anyChildHasChanged; + } + graphsToCheckStack.removeLast(); + } + + return changedGraphs[entrypointGraph]!; + } + + /// Whether [input] has a change visible at [phaseNumber]. + Future _hasInputChanged({ + required AssetId input, + required int phaseNumber, + }) async { + final inputNode = assetGraph.get(input)!; + if (inputNode.type == NodeType.generated) { + if (inputNode.generatedNodeConfiguration!.phaseNumber >= phaseNumber) { + // It's not readable in this phase. + return false; + } + // Ensure that the input was built, so [changedOutputs] is updated. + if (!processedOutputs.contains(input)) { + await _buildOutput(inputNode.id); + } + if (changedOutputs.contains(input)) { + return true; + } + } else if (inputNode.type == NodeType.glob) { + // Ensure that the glob was evaluated, so [changedOutputs] is updated. + if (!processedOutputs.contains(input)) { + await _buildGlobNode(input); + } + if (changedOutputs.contains(input)) { + return true; + } + } else if (inputNode.type == NodeType.source) { + if (changedInputs.contains(input)) { + return true; + } + } else if (inputNode.type == NodeType.missingSource) { + // It's only a newly-deleted asset if it's also in [deletedAssets]. + if (deletedAssets.contains(input)) { + return true; + } + } + return false; + } + /// Whether the post process build step [buildStepId] should run. /// /// It should run if its builder options changed or its input changed. @@ -1092,6 +1231,7 @@ class Build { outputNode = assetGraph.updateNode(output, (nodeBuilder) { nodeBuilder.generatedNodeState ..inputs.replace(usedInputs) + ..resolverEntrypoints.replace(inputTracker.resolverEntrypoints) ..result = result; nodeBuilder.digest = digest; }); diff --git a/build_runner_core/lib/src/generate/input_tracker.dart b/build_runner_core/lib/src/generate/input_tracker.dart index 110150924..718d7039d 100644 --- a/build_runner_core/lib/src/generate/input_tracker.dart +++ b/build_runner_core/lib/src/generate/input_tracker.dart @@ -22,6 +22,7 @@ class InputTracker { Map.identity(); final HashSet _inputs = HashSet(); + final HashSet _resolverEntrypoints = HashSet(); /// Creates an input tracker. /// @@ -36,14 +37,13 @@ class InputTracker { void add(AssetId input) => _inputs.add(input); - void addAll({ - required AssetId primaryInput, - required Iterable inputs, - }) => _inputs.addAll(inputs); + void addResolverEntrypoint(AssetId graph) => _resolverEntrypoints.add(graph); Set get inputs => _inputs; + Set get resolverEntrypoints => _resolverEntrypoints; void clear() { - inputs.clear(); + _inputs.clear(); + _resolverEntrypoints.clear(); } } diff --git a/build_runner_core/lib/src/generate/single_step_reader_writer.dart b/build_runner_core/lib/src/generate/single_step_reader_writer.dart index 3ffef755d..0b8904678 100644 --- a/build_runner_core/lib/src/generate/single_step_reader_writer.dart +++ b/build_runner_core/lib/src/generate/single_step_reader_writer.dart @@ -222,6 +222,7 @@ class SingleStepReaderWriter extends AssetReader Future _isReadable( AssetId id, { bool catchInvalidInputs = false, + bool track = true, }) async { try { _checkInvalidInput(id); @@ -234,13 +235,13 @@ class SingleStepReaderWriter extends AssetReader } if (_runningBuild == null) { - inputTracker.add(id); + if (track) inputTracker.add(id); return _delegate.canRead(id); } final node = _runningBuild.assetGraph.get(id); if (node == null) { - inputTracker.add(id); + if (track) inputTracker.add(id); _runningBuild.assetGraph.add(AssetNode.missingSource(id)); return false; } @@ -252,15 +253,19 @@ class SingleStepReaderWriter extends AssetReader // it's an output of a generator running in parallel, which means it's // hidden and can't be an input. if (!readability.inSamePhase) { - inputTracker.add(id); + if (track) inputTracker.add(id); } return readability.canRead; } @override - Future canRead(AssetId id) async { - final isReadable = await _isReadable(id, catchInvalidInputs: true); + Future canRead(AssetId id, {bool track = true}) async { + final isReadable = await _isReadable( + id, + catchInvalidInputs: true, + track: track, + ); if (!isReadable) return false; if (_runningBuild == null) return true; @@ -299,8 +304,12 @@ class SingleStepReaderWriter extends AssetReader } @override - Future readAsString(AssetId id, {Encoding encoding = utf8}) async { - final isReadable = await _isReadable(id); + Future readAsString( + AssetId id, { + Encoding encoding = utf8, + bool track = true, + }) async { + final isReadable = await _isReadable(id, track: track); if (!isReadable) { throw AssetNotFoundException(id); } @@ -470,6 +479,13 @@ class SingleStepReaderWriter extends AssetReader ); } + @override + Future readAtPhase(AssetId id) async { + return await canRead(id, track: false) + ? await readAsString(id, track: false) + : null; + } + @override bool hasChanged(AssetId id, {required int comparedToPhase}) { if (comparedToPhase == phase) return false; diff --git a/build_runner_core/test/invalidation/huge_resolved_graph_invalidation_test.dart b/build_runner_core/test/invalidation/huge_resolved_graph_invalidation_test.dart new file mode 100644 index 000000000..fb2f328b4 --- /dev/null +++ b/build_runner_core/test/invalidation/huge_resolved_graph_invalidation_test.dart @@ -0,0 +1,51 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; + +import 'invalidation_tester.dart'; + +/// Invalidation tests for huge resolved transitive deps graphs. +/// +/// In addition to testing serialized JSON size, this ensures there are no stack +/// overflows due to walking the graph using recursion. +void main() { + late InvalidationTester tester; + + setUp(() { + tester = InvalidationTester(); + }); + + // This was sufficient to cause a stack overflow when `Build` used a recursive + // algorithm to check for changes to graphs. + final size = 1500; + + group('a.1 <== a.2, a.2 resolves z1 --> ... --> z$size', () { + setUp(() { + tester.sources(['a.1', for (var i = 1; i != (size + 1); ++i) 'z$i']); + tester.importGraph({ + for (var i = 1; i != size; ++i) 'z$i': ['z${i + 1}'], + }); + tester.builder(from: '.1', to: '.2') + ..resolvesOther('z1') + ..writes('.2'); + }); + + test('a.2 is built', () async { + expect(await tester.build(), Result(written: ['a.2'])); + }); + + test('change z$size, a.2 is rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'z$size'), Result(written: ['a.2'])); + }); + + test('asset graph size', () async { + await tester.build(); + // Currently measured at 276k; doesn't need to be a precise check, this is + // to guard against quadratic behaviour which would cause size >> 1Mb. + expect(tester.assetGraphSize, lessThan(300000)); + }); + }); +} diff --git a/build_runner_core/test/invalidation/invalidation_tester.dart b/build_runner_core/test/invalidation/invalidation_tester.dart index 943739542..8b4e41e33 100644 --- a/build_runner_core/test/invalidation/invalidation_tester.dart +++ b/build_runner_core/test/invalidation/invalidation_tester.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:build/build.dart'; +import 'package:build_runner_core/src/util/constants.dart'; import 'package:build_test/build_test.dart'; import 'package:built_collection/built_collection.dart'; import 'package:crypto/crypto.dart'; @@ -154,7 +155,7 @@ class InvalidationTester { // Make the requested updates. if (change != null) { assets[change.assetId] = - '${_imports(change.assetId)}}\n// ${++_outputNumber}'; + '${_imports(change.assetId)}\n// ${++_outputNumber}'; } if (delete != null) { if (assets.containsKey(delete.assetId)) { @@ -203,6 +204,10 @@ class InvalidationTester { ? Result(written: written, deleted: deleted) : Result.failure(written: written, deleted: deleted); } + + /// The size of the asset graph that was written by [build], in bytes. + int get assetGraphSize => + _readerWriter!.testing.readBytes(AssetId('pkg', assetGraphPath)).length; } /// Strategy used by generators for outputting files. diff --git a/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart b/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart index db79e2521..2bca6566a 100644 --- a/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart +++ b/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart @@ -22,7 +22,7 @@ void main() { group('a.1 <== a.2, a.2 resolves: a.1 --> za --> zb', () { setUp(() { - tester.sources(['a.1', 'za', 'zb']); + tester.sources(['a.1', 'za', 'zb', 'zc']); tester.importGraph({ 'a.1': ['za'], 'za': ['zb'], @@ -46,6 +46,44 @@ void main() { await tester.build(); expect(await tester.build(change: 'zb'), Result(written: ['a.2'])); }); + + test('the import graph can change between builds', () async { + await tester.build(); + // Initially there is no import of 'zc', so changing it does nothing. + expect(await tester.build(change: 'zc'), Result()); + // But changing 'zb' triggers rebuild. + expect(await tester.build(change: 'zb'), Result(written: ['a.2'])); + + // Switch the import from 'za' from 'zb' onto 'zc'. + tester.importGraph({ + 'a.1': ['za'], + 'za': ['zc'], + }); + expect(await tester.build(change: 'za'), Result(written: ['a.2'])); + + // Now changing 'zb' does nothing. + expect(await tester.build(change: 'zb'), Result()); + // But changing 'zc' triggers rebuild. + expect(await tester.build(change: 'zc'), Result(written: ['a.2'])); + }); + + test( + 'parts of the import graph that are not recomputed are retained', + () async { + await tester.build(); + // The second build does not need to compute the import graph. + expect(await tester.build(), Result()); + // But the third build needs the import graph. So, it needs to + // have been retained from the first build. + expect(await tester.build(change: 'zb'), Result(written: ['a.2'])); + }, + ); + + test('missing import triggers build when it appears', () async { + tester.sources(['a.1', 'za']); + expect(await tester.build(), Result(written: ['a.2'])); + expect(await tester.build(create: 'zb'), Result(written: ['a.2'])); + }); }); // Transitive dependencies via files generated in earlier phases. @@ -112,4 +150,355 @@ void main() { expect(await tester.build(change: 'a.1'), Result(written: ['a.2'])); }); }); + + // Various dependencies onto five node source dependency graph. + group('a.1 <== a.2, a.2 resolves: z2 -> ..., ' + 'a.3 <== a.4, a.4 resolves: z4 -> ..., ' + '...', () { + setUp(() { + tester.sources([ + 'a.1', + 'a.3', + 'a.5', + 'a.7', + 'a.9', + 'z2', + 'z4', + 'z6', + 'z8', + 'z10', + ]); + // z2 ----> z4 ---- + // \-> z6 -> z10 -\-> z8 + tester.importGraph({ + 'z2': ['z4', 'z6'], + 'z4': ['z8'], + 'z6': ['z10'], + 'z10': ['z8'], + }); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..resolvesOther('z2') + ..writes('.2'); + tester.builder(from: '.3', to: '.4') + ..reads('.3') + ..resolvesOther('z4') + ..writes('.4'); + tester.builder(from: '.5', to: '.6') + ..reads('.5') + ..resolvesOther('z6') + ..writes('.6'); + tester.builder(from: '.7', to: '.8') + ..reads('.7') + ..resolvesOther('z8') + ..writes('.8'); + tester.builder(from: '.9', to: '.10') + ..reads('.9') + ..resolvesOther('z10') + ..writes('.10'); + }); + + test('a.2+a.4+a.6+a.8+a.10 are built', () async { + expect( + await tester.build(), + Result(written: ['a.2', 'a.4', 'a.6', 'a.8', 'a.10']), + ); + }); + + test('change z2, a.2 is built', () async { + await tester.build(); + expect(await tester.build(change: 'z2'), Result(written: ['a.2'])); + }); + + test('change z4, a.2+a.4 are built', () async { + await tester.build(); + expect(await tester.build(change: 'z4'), Result(written: ['a.2', 'a.4'])); + }); + + test('change z6, a.2+a.6 are built', () async { + await tester.build(); + expect(await tester.build(change: 'z6'), Result(written: ['a.2', 'a.6'])); + }); + + test('change z8, a.2+a.4+a.6+a.8+a.10 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z8'), + Result(written: ['a.2', 'a.4', 'a.6', 'a.8', 'a.10']), + ); + }); + + test('change z10, a.2+a.6+a.10 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z10'), + Result(written: ['a.2', 'a.6', 'a.10']), + ); + }); + }); + + // As the previous group, but "z" sources are all generated. + group('a.1 <== a.2, a.2 resolves: z.12 -> ..., ' + 'a.3 <== a.4, a.4 resolves: z.14 -> ..., ' + '...', () { + setUp(() { + tester.sources([ + 'a.1', + 'a.3', + 'a.5', + 'a.7', + 'a.9', + 'z.11', + 'z.13', + 'z.15', + 'z.17', + 'z.19', + ]); + // z.12 ----> z.14 ---- + // \-> z.16 -> z.20 -\-> z.18 + tester.importGraph({ + 'z.12': ['z.14', 'z.16'], + 'z.14': ['z.18'], + 'z.16': ['z.20'], + 'z.20': ['z.18'], + }); + + // The "z" generators go first so their output is available. + tester.builder(from: '.11', to: '.12') + ..reads('.11') + ..writes('.12'); + tester.builder(from: '.13', to: '.14') + ..reads('.13') + ..writes('.14'); + tester.builder(from: '.15', to: '.16') + ..reads('.15') + ..writes('.16'); + tester.builder(from: '.17', to: '.18') + ..reads('.17') + ..writes('.18'); + tester.builder(from: '.19', to: '.20') + ..reads('.19') + ..writes('.20'); + + // Followed by the "a" generators. + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..resolvesOther('z.12') + ..writes('.2'); + tester.builder(from: '.3', to: '.4') + ..reads('.3') + ..resolvesOther('z.14') + ..writes('.4'); + tester.builder(from: '.5', to: '.6') + ..reads('.5') + ..resolvesOther('z.16') + ..writes('.6'); + tester.builder(from: '.7', to: '.8') + ..reads('.7') + ..resolvesOther('z.18') + ..writes('.8'); + tester.builder(from: '.9', to: '.10') + ..reads('.9') + ..resolvesOther('z.20') + ..writes('.10'); + }); + + test('a.2+a.4+a.6+a.8+a.10 are built', () async { + expect( + await tester.build(), + Result( + written: [ + 'z.12', + 'z.14', + 'z.16', + 'z.18', + 'z.20', + 'a.2', + 'a.4', + 'a.6', + 'a.8', + 'a.10', + ], + ), + ); + }); + + test('change z.11, z.12+a.2 is built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.11'), + Result(written: ['z.12', 'a.2']), + ); + }); + + test('change z.13, z.14+a.2+a.4 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.13'), + Result(written: ['z.14', 'a.2', 'a.4']), + ); + }); + + test('change z.15, z.16+a.2+a.6 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.15'), + Result(written: ['z.16', 'a.2', 'a.6']), + ); + }); + + test('change z.17, z.18+a.2+a.4+a.6+a.8+a.10 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.17'), + Result(written: ['z.18', 'a.2', 'a.4', 'a.6', 'a.8', 'a.10']), + ); + }); + + test('change z.19, z.20+a.2+a.6+a.10 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.19'), + Result(written: ['z.20', 'a.2', 'a.6', 'a.10']), + ); + }); + }); + + // As the previous group, but builder order changed so z.15 <== z.16 is + // generated after a.1 <== a.2. + group('builders reordered ' + 'a.1 <== a.2, a.2 resolves: z.12 -> ..., ' + 'a.3 <== a.4, a.4 resolves: z.14 -> ..., ' + '...', () { + setUp(() { + tester.sources([ + 'a.1', + 'a.3', + 'a.5', + 'a.7', + 'a.9', + 'z.11', + 'z.13', + 'z.15', + 'z.17', + 'z.19', + ]); + // z.12 ----> z.14 ---- + // \-> z.16 -> z.20 -\-> z.18 + tester.importGraph({ + 'z.12': ['z.14', 'z.16'], + 'z.14': ['z.18'], + 'z.16': ['z.20'], + 'z.20': ['z.18'], + }); + + // The "z" generators go first so their output is available; + // except z.15 <== z.16 which is moved to the block below. + tester.builder(from: '.11', to: '.12') + ..reads('.11') + ..writes('.12'); + tester.builder(from: '.13', to: '.14') + ..reads('.13') + ..writes('.14'); + tester.builder(from: '.17', to: '.18') + ..reads('.17') + ..writes('.18'); + tester.builder(from: '.19', to: '.20') + ..reads('.19') + ..writes('.20'); + + // Followed by the "a" generators. + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..resolvesOther('z.12') + ..writes('.2'); + tester.builder(from: '.15', to: '.16') // And z.15 <== z.16. + ..reads('.15') + ..writes('.16'); + tester.builder(from: '.3', to: '.4') + ..reads('.3') + ..resolvesOther('z.14') + ..writes('.4'); + tester.builder(from: '.5', to: '.6') + ..reads('.5') + ..resolvesOther('z.16') + ..writes('.6'); + tester.builder(from: '.7', to: '.8') + ..reads('.7') + ..resolvesOther('z.18') + ..writes('.8'); + tester.builder(from: '.9', to: '.10') + ..reads('.9') + ..resolvesOther('z.20') + ..writes('.10'); + }); + + // Same as previous group; unchanged by z.15 <== z.16 change. + test('a.2+a.4+a.6+a.8+a.10 are built', () async { + expect( + await tester.build(), + Result( + written: [ + 'z.12', + 'z.14', + 'z.16', + 'z.18', + 'z.20', + 'a.2', + 'a.4', + 'a.6', + 'a.8', + 'a.10', + ], + ), + ); + }); + + // Same as previous group; unchanged by z.15 <== z.16 change. + test('change z.11, z.12+a.2 is built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.11'), + Result(written: ['z.12', 'a.2']), + ); + }); + + // Same as previous group; unchanged by z.15 <== z.16 change. + test('change z.13, z.14+a.2+a.4 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.13'), + Result(written: ['z.14', 'a.2', 'a.4']), + ); + }); + + // Changed from previous group: z.15 <== z.16 is now generated too late to + // affect a.2, so it is not regenerated. + test('change z.15, z.16+a.2+a.6 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.15'), + Result(written: ['z.16', 'a.6']), + ); + }); + + // Same as previous group; unchanged by z.15 <== z.16 change. + test('change z.17, z.18+a.2+a.4+a.6+a.8+a.10 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.17'), + Result(written: ['z.18', 'a.2', 'a.4', 'a.6', 'a.8', 'a.10']), + ); + }); + + // Changed from previous group: z.15 <== z.16 is now generated too late to + // affect a.2, so it is not regenerated. + test('change z.19, z.20+a.2+a.6+a.10 are built', () async { + await tester.build(); + expect( + await tester.build(change: 'z.19'), + Result(written: ['z.20', 'a.6', 'a.10']), + ); + }); + }); } diff --git a/build_test/CHANGELOG.md b/build_test/CHANGELOG.md index 186fd1292..a1b79351c 100644 --- a/build_test/CHANGELOG.md +++ b/build_test/CHANGELOG.md @@ -23,7 +23,9 @@ `TestReaderWriter` instead. - Breaking change: `TestReaderWriter.assetsRead` does not take into account details of the build, it's just what was actually read. Use - `TestReaderWriter.inputsTracked` for what was recorded as an input. + `TestReaderWriter.inputsTracked` for what was recorded as an input. Note that + resolver entrypoints are now tracked separately from inputs, see + `TestReaderWriter.resolverEntrypointsTracked`. - Breaking change: Remove `StubAssetReader`. Use `TestReaderWriter` instead. - `TestReaderWriter` writes and deletes are notified to `FakeWatcher`. - `TestReaderWriter` tracks `assetsWritten`. diff --git a/build_test/lib/src/in_memory_reader_writer.dart b/build_test/lib/src/in_memory_reader_writer.dart index efe6f2dd8..a8fee3099 100644 --- a/build_test/lib/src/in_memory_reader_writer.dart +++ b/build_test/lib/src/in_memory_reader_writer.dart @@ -191,6 +191,12 @@ class _ReaderWriterTestingImpl implements ReaderWriterTesting { .expand((tracker) => tracker.inputs) .toSet(); + @override + Iterable get resolverEntrypointsTracked => + InputTracker.inputTrackersForTesting[_readerWriter.filesystem]! + .expand((tracker) => tracker.resolverEntrypoints) + .toSet(); + @override Iterable get assetsRead => _readerWriter.assetsRead; diff --git a/build_test/lib/src/test_reader_writer.dart b/build_test/lib/src/test_reader_writer.dart index ff6f67b3c..b8c743df3 100644 --- a/build_test/lib/src/test_reader_writer.dart +++ b/build_test/lib/src/test_reader_writer.dart @@ -32,6 +32,13 @@ abstract interface class ReaderWriterTesting { /// The assets that have been recorded as inputs of the build. Iterable get inputsTracked; + /// The assets that the build resolved using the analyzer. + /// + /// Only the entrypoints are recorded, but all sources reachable transitively + /// via its directives will be treated as dependencies of the build for + /// invalidation purposes. + Iterable get resolverEntrypointsTracked; + /// The assets that have been read via the [TestReaderWriter]'s non-test /// APIs. ///