diff --git a/build/CHANGELOG.md b/build/CHANGELOG.md index bcc426e48..aaee03528 100644 --- a/build/CHANGELOG.md +++ b/build/CHANGELOG.md @@ -14,6 +14,7 @@ - Refactor `BuildCacheReader` to `BuildCacheAssetPathProvider`. - Refactor `FileBasedAssetReader` and `FileBasedAssetWriter` to `ReaderWriter`. - Move `BuildStepImpl` to `build_runner_core`, use `SingleStepReader` directly. +- Add `LibraryCycleGraphLoader` for loading transitive deps for analysis. ## 2.4.2 diff --git a/build/lib/src/internal.dart b/build/lib/src/internal.dart index c7e53ce6b..18a9378f3 100644 --- a/build/lib/src/internal.dart +++ b/build/lib/src/internal.dart @@ -6,6 +6,13 @@ /// `build_runner_core` and `build_test` only. library; +export 'library_cycle_graph/asset_deps.dart'; +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_reader.dart'; +export 'library_cycle_graph/phased_value.dart'; export 'state/asset_finder.dart'; export 'state/asset_path_provider.dart'; export 'state/filesystem.dart'; diff --git a/build/lib/src/library_cycle_graph/asset_deps.dart b/build/lib/src/library_cycle_graph/asset_deps.dart new file mode 100644 index 000000000..035a489b0 --- /dev/null +++ b/build/lib/src/library_cycle_graph/asset_deps.dart @@ -0,0 +1,27 @@ +// 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:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; + +import '../../build.dart' hide Builder; + +part 'asset_deps.g.dart'; + +/// Dependencies of a Dart source asset. +/// +/// A "dependency" is another Dart source mentioned in `import`, `export`, +/// `part` or `part of`. +/// +/// Missing or not-yet-generated sources can be represented by this class: they +/// have no deps. +abstract class AssetDeps implements Built { + static final AssetDeps empty = _$AssetDeps._(deps: BuiltSet()); + + BuiltSet get deps; + + factory AssetDeps(Iterable deps) => + _$AssetDeps._(deps: BuiltSet.of(deps)); + 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 new file mode 100644 index 000000000..49e909165 --- /dev/null +++ b/build/lib/src/library_cycle_graph/asset_deps.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'asset_deps.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$AssetDeps extends AssetDeps { + @override + final BuiltSet deps; + + factory _$AssetDeps([void Function(AssetDepsBuilder)? updates]) => + (new AssetDepsBuilder()..update(updates))._build(); + + _$AssetDeps._({required this.deps}) : super._() { + BuiltValueNullFieldError.checkNotNull(deps, r'AssetDeps', 'deps'); + } + + @override + AssetDeps rebuild(void Function(AssetDepsBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + AssetDepsBuilder toBuilder() => new AssetDepsBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is AssetDeps && deps == other.deps; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, deps.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'AssetDeps') + ..add('deps', deps)).toString(); + } +} + +class AssetDepsBuilder implements Builder { + _$AssetDeps? _$v; + + SetBuilder? _deps; + SetBuilder get deps => _$this._deps ??= new SetBuilder(); + set deps(SetBuilder? deps) => _$this._deps = deps; + + AssetDepsBuilder(); + + AssetDepsBuilder get _$this { + final $v = _$v; + if ($v != null) { + _deps = $v.deps.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(AssetDeps other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$AssetDeps; + } + + @override + void update(void Function(AssetDepsBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + AssetDeps build() => _build(); + + _$AssetDeps _build() { + _$AssetDeps _$result; + try { + _$result = _$v ?? new _$AssetDeps._(deps: deps.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'deps'; + deps.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'AssetDeps', + _$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/asset_deps_loader.dart b/build/lib/src/library_cycle_graph/asset_deps_loader.dart new file mode 100644 index 000000000..16f636d12 --- /dev/null +++ b/build/lib/src/library_cycle_graph/asset_deps_loader.dart @@ -0,0 +1,59 @@ +// 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:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; + +import '../asset/id.dart'; +import 'asset_deps.dart'; +import 'phased_reader.dart'; +import 'phased_value.dart'; + +/// Loads Dart source assets to [PhasedValue]s of [AssetDeps]. +class AssetDepsLoader { + static const _ignoredSchemes = ['dart', 'dart-ext']; + + final PhasedReader _reader; + + AssetDepsLoader(this._reader); + + /// The phase that this loader is reading build state at. + int get phase => _reader.phase; + + /// Reads [id] + /// + /// If [id] will be generated at a phase equal to or after [phase], the + /// result is incomplete, with an expirey phase. + Future> load(AssetId id) async { + final content = await _reader.readPhased(id); + + return PhasedValue((b) { + b.values.addAll(content.values.map((content) => _parse(id, content))); + }); + } + + /// Parses directives in [content] to return an [AssetDeps]. + ExpiringValue _parse(AssetId id, ExpiringValue content) { + final result = + ExpiringValueBuilder()..expiresAfter = content.expiresAfter; + + final parsed = + parseString(content: content.value, throwIfDiagnostics: false).unit; + + final depsNodeBuilder = AssetDepsBuilder(); + + for (final directive in parsed.directives) { + if (directive is! UriBasedDirective) continue; + final uri = directive.uri.stringValue; + if (uri == null) continue; + final parsedUri = Uri.parse(uri); + if (_ignoredSchemes.any(parsedUri.isScheme)) continue; + final assetId = AssetId.resolve(parsedUri, from: id); + depsNodeBuilder.deps.add(assetId); + } + + result.value = depsNodeBuilder.build(); + return result.build(); + } +} diff --git a/build/lib/src/library_cycle_graph/library_cycle.dart b/build/lib/src/library_cycle_graph/library_cycle.dart new file mode 100644 index 000000000..0b2432a9c --- /dev/null +++ b/build/lib/src/library_cycle_graph/library_cycle.dart @@ -0,0 +1,22 @@ +// 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:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; + +import '../asset/id.dart'; + +part 'library_cycle.g.dart'; + +/// A set of Dart source assets that mutually depend on each other. +/// +/// This means they have to be compiled as a single unit. +abstract class LibraryCycle + implements Built { + BuiltSet get ids; + + factory LibraryCycle([void Function(LibraryCycleBuilder) updates]) = + _$LibraryCycle; + LibraryCycle._(); +} diff --git a/build/lib/src/library_cycle_graph/library_cycle.g.dart b/build/lib/src/library_cycle_graph/library_cycle.g.dart new file mode 100644 index 000000000..6db8facb5 --- /dev/null +++ b/build/lib/src/library_cycle_graph/library_cycle.g.dart @@ -0,0 +1,104 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_cycle.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$LibraryCycle extends LibraryCycle { + @override + final BuiltSet ids; + + factory _$LibraryCycle([void Function(LibraryCycleBuilder)? updates]) => + (new LibraryCycleBuilder()..update(updates))._build(); + + _$LibraryCycle._({required this.ids}) : super._() { + BuiltValueNullFieldError.checkNotNull(ids, r'LibraryCycle', 'ids'); + } + + @override + LibraryCycle rebuild(void Function(LibraryCycleBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + LibraryCycleBuilder toBuilder() => new LibraryCycleBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is LibraryCycle && ids == other.ids; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, ids.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'LibraryCycle') + ..add('ids', ids)).toString(); + } +} + +class LibraryCycleBuilder + implements Builder { + _$LibraryCycle? _$v; + + SetBuilder? _ids; + SetBuilder get ids => _$this._ids ??= new SetBuilder(); + set ids(SetBuilder? ids) => _$this._ids = ids; + + LibraryCycleBuilder(); + + LibraryCycleBuilder get _$this { + final $v = _$v; + if ($v != null) { + _ids = $v.ids.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(LibraryCycle other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$LibraryCycle; + } + + @override + void update(void Function(LibraryCycleBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + LibraryCycle build() => _build(); + + _$LibraryCycle _build() { + _$LibraryCycle _$result; + try { + _$result = _$v ?? new _$LibraryCycle._(ids: ids.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'ids'; + ids.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'LibraryCycle', + _$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/library_cycle_graph.dart b/build/lib/src/library_cycle_graph/library_cycle_graph.dart new file mode 100644 index 000000000..61b05f675 --- /dev/null +++ b/build/lib/src/library_cycle_graph/library_cycle_graph.dart @@ -0,0 +1,48 @@ +// 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:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; + +import '../asset/id.dart'; +import 'library_cycle.dart'; + +part 'library_cycle_graph.g.dart'; + +/// A directed acyclic graph of [LibraryCycle]s. +abstract class LibraryCycleGraph + implements Built { + LibraryCycle get root; + BuiltList get children; + + factory LibraryCycleGraph([void Function(LibraryCycleGraphBuilder) updates]) = + _$LibraryCycleGraph; + LibraryCycleGraph._(); + + /// All subgraphs in the graph, including the root. + Iterable get transitiveGraphs { + final result = Set.identity(); + final nextGraphs = [this]; + + while (nextGraphs.isNotEmpty) { + final graph = nextGraphs.removeLast(); + if (result.add(graph)) { + nextGraphs.addAll(graph.children); + } + } + + return result; + } + + /// All assets in the graph, including the root. + // TODO(davidmorgan): for best performance the graph should usually stay as a + // 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) { + 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 new file mode 100644 index 000000000..ab683aafc --- /dev/null +++ b/build/lib/src/library_cycle_graph/library_cycle_graph.g.dart @@ -0,0 +1,133 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_cycle_graph.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$LibraryCycleGraph extends LibraryCycleGraph { + @override + final LibraryCycle root; + @override + final BuiltList children; + + factory _$LibraryCycleGraph([ + void Function(LibraryCycleGraphBuilder)? updates, + ]) => (new LibraryCycleGraphBuilder()..update(updates))._build(); + + _$LibraryCycleGraph._({required this.root, required this.children}) + : super._() { + BuiltValueNullFieldError.checkNotNull(root, r'LibraryCycleGraph', 'root'); + BuiltValueNullFieldError.checkNotNull( + children, + r'LibraryCycleGraph', + 'children', + ); + } + + @override + LibraryCycleGraph rebuild(void Function(LibraryCycleGraphBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + LibraryCycleGraphBuilder toBuilder() => + new LibraryCycleGraphBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is LibraryCycleGraph && + root == other.root && + children == other.children; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, root.hashCode); + _$hash = $jc(_$hash, children.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'LibraryCycleGraph') + ..add('root', root) + ..add('children', children)) + .toString(); + } +} + +class LibraryCycleGraphBuilder + implements Builder { + _$LibraryCycleGraph? _$v; + + LibraryCycleBuilder? _root; + LibraryCycleBuilder get root => _$this._root ??= new LibraryCycleBuilder(); + set root(LibraryCycleBuilder? root) => _$this._root = root; + + ListBuilder? _children; + ListBuilder get children => + _$this._children ??= new ListBuilder(); + set children(ListBuilder? children) => + _$this._children = children; + + LibraryCycleGraphBuilder(); + + LibraryCycleGraphBuilder get _$this { + final $v = _$v; + if ($v != null) { + _root = $v.root.toBuilder(); + _children = $v.children.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(LibraryCycleGraph other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$LibraryCycleGraph; + } + + @override + void update(void Function(LibraryCycleGraphBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + LibraryCycleGraph build() => _build(); + + _$LibraryCycleGraph _build() { + _$LibraryCycleGraph _$result; + try { + _$result = + _$v ?? + new _$LibraryCycleGraph._( + root: root.build(), + children: children.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'root'; + root.build(); + _$failedField = 'children'; + children.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'LibraryCycleGraph', + _$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/library_cycle_graph_loader.dart b/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart new file mode 100644 index 000000000..2b908756f --- /dev/null +++ b/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart @@ -0,0 +1,395 @@ +// 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:graphs/graphs.dart'; + +import '../asset/id.dart'; +import 'asset_deps.dart'; +import 'asset_deps_loader.dart'; +import 'library_cycle.dart'; +import 'library_cycle_graph.dart'; +import 'phased_value.dart'; + +/// Loads [LibraryCycleGraph]s during a phased build. +/// +/// "Phased build" means: +/// +/// - The build has a "timeline" described by an `int` phase number +/// - Build actions can see output from earlier phases but not from the +/// current phase or later phases +/// - Build phases do _not_ run monotonically, but are interleaved; an action +/// in phase x might trigger an action in phase y, where y < x +/// +/// And these further details apply to `build_runner` builds: +/// +/// - Action outputs are predictable: for any file that might be produced, it +/// is known before the build starts what build action might produce it, and +/// in what phase +/// +/// This complicates dependency tracking in two notable ways. +/// +/// Firstly, it means that the transitive dependencies of a node can change: if +/// any dependency is a generated source, then at the phase _after_ the one in +/// which it's generated, it must be parsed and any direct and indirect +/// dependencies added. +/// +/// Secondly, because the loader is for use _during_ the build, it might be that +/// not all files have been generated yet. So, results must be returned based on +/// incomplete data, as needed. +class LibraryCycleGraphLoader { + /// The dependencies of loaded assets, as far as is known. + /// + /// Source files do not change during the build, so as soon as loaded + /// their value is a [PhasedValue.fixed] that is valid for the whole build. + /// + /// A generated file that could not yet be loaded is a + /// [PhasedValue.unavailable] specify the phase when it will be generated. + /// When to finish loading the asset is tracked in [_assetDepsToLoadByPhase]. + /// + /// A generated file that _has_ been loaded is a [PhasedValue.generated] + /// specifying both the phase it was generated at and its parsed dependencies. + final Map> _assetDeps = {}; + + /// Generated assets that were loaded before they were generated. + /// + /// The `key` is the phase at which they have been generated and can be read. + final Map> _assetDepsToLoadByPhase = {}; + + /// Newly [_load]ed assets to process for the first time in [_buildCycles]. + Set _newAssets = {}; + + /// All loaded library cycles, by asset. + final Map> _cycles = {}; + + /// All loaded library cycle graphs, by asset. + /// + /// Graphs that expire have entries in [_graphsToComputeByPhase]. + final Map> _graphs = {}; + + /// Graphs that expire, by the phase at which the update value should be + /// computed: expirey phase + 1. + final Map> _graphsToComputeByPhase = {}; + + /// Clears all data. + void clear() { + _assetDeps.clear(); + _assetDepsToLoadByPhase.clear(); + _newAssets.clear(); + _cycles.clear(); + _graphs.clear(); + } + + /// Loads [id] and its transitive dependencies at all phases available to + /// [assetDepsLoader]. + /// + /// Assets are loaded to [_assetDeps]. + /// + /// If assets are encountered that have not yet been generated, they are + /// added to [_assetDepsToLoadByPhase], and will be loaded eagerly by any + /// call to `_load` with an `assetDepsLoader` at a late enough phase. + /// + /// Newly seen assets are noted in [_newAssets] for further processing by + /// [_buildCycles]. + Future _load(AssetDepsLoader assetDepsLoader, AssetId id) async { + final idsToLoad = [id]; + // Finish loading any assets that were `_load`ed before they were generated + // and have now been generated. + for (final phase in _assetDepsToLoadByPhase.keys.toList(growable: false)) { + if (phase <= assetDepsLoader.phase) { + idsToLoad.addAll(_assetDepsToLoadByPhase.remove(phase)!); + } + } + + while (idsToLoad.isNotEmpty) { + final idToLoad = idsToLoad.removeLast(); + + // Nothing to do if deps were already loaded, unless they expire and + // [assetDepsLoader] is at a late enough phase to see the updated value. + final alreadyLoadedAssetDeps = _assetDeps[idToLoad]; + if (alreadyLoadedAssetDeps != null && + !alreadyLoadedAssetDeps.isExpiredAt(phase: assetDepsLoader.phase)) { + continue; + } + + final assetDeps = + _assetDeps[idToLoad] = await assetDepsLoader.load(idToLoad); + + // First time seeing the asset, mark for computation of cycles and + // graphs given the initial state of the build. + if (alreadyLoadedAssetDeps == null) { + _newAssets.add(idToLoad); + } + + if (assetDeps.isComplete) { + // "isComplete" means it's a source file or a generated value that has + // already been generated. It has deps, so mark them for loading. + for (final dep in assetDeps.lastValue.deps) { + idsToLoad.add(dep); + } + } else { + // It's a generated source that has not yet been generated. Mark it for + // loading later. + (_assetDepsToLoadByPhase[assetDeps.values.last.expiresAfter! + 1] ??= + {}) + .add(idToLoad); + } + } + } + + /// Computes [_cycles] for all [_newAssets] at phase 0, then for all assets + /// with expiring graphs up to and including [upToPhase]. + /// + /// Call [_load] first so there are [_newAssets] assets to process. Clears + /// [_newAssets] of processed IDs. + /// + /// Graphs which are still not complete--they have one or more assets that + /// expire after [upToPhase]--are added to [_graphsToComputeByPhase] to + /// be completed later. + /// [_graphsToComputeByPhase]. + void _buildCycles(int upToPhase) { + // Process phases that have work to do in ascending order. + while (true) { + int phase; + Set idsToComputeCyclesFrom; + if (_newAssets.isNotEmpty) { + // New assets: work to do at phase 0, the initial build state. + phase = 0; + idsToComputeCyclesFrom = _newAssets; + _newAssets = {}; + } else { + // Work through phases <= `upToPhase` at which graphs expire, + // so there are new values to compute. + if (_graphsToComputeByPhase.isEmpty) break; + phase = _graphsToComputeByPhase.keys.reduce(min); + if (phase > upToPhase) break; + idsToComputeCyclesFrom = _graphsToComputeByPhase.remove(phase)!; + } + + // Edges for strongly connected components computation. + Iterable edgesFromId(AssetId id) { + final deps = _assetDeps[id]!.valueAt(phase: phase).deps; + + // Check edge against cycles that have already been computed + // at the current `phase`. Newly discovered assets at the same phase + // cannot be part of already-computed cycles, so prevent recomputation + // of those same cycles by hiding deps onto them. + return deps.where( + (id) => _cycles[id]?.isExpiredAt(phase: phase) ?? true, + ); + } + + // Do the strongly connected components computation and convert + // from its output, a list of lists of IDs, to a list of [LibraryCycle]. + final newComponentLists = stronglyConnectedComponents( + idsToComputeCyclesFrom, + edgesFromId, + ); + final newCycles = + newComponentLists.map((list) { + // Compare to the library cycle computed at `phase - 1`. If the + // cycles are the same size then they must have the same contents, + // because cycles only change by growing as phases progress. In that + // case, reuse the existing [LibraryCycle]. + final maybePhasedCycle = _cycles[list.first]; + if (maybePhasedCycle != null) { + final value = maybePhasedCycle.valueAt(phase: phase - 1); + if (value.ids.length == list.length) { + return value; + } + } + // The cycle is new or has changed, return a new value. + return LibraryCycle((b) => b..ids.replace(list)); + }).toList(); + + // Build graphs from cycles. + _buildGraphs(phase, newCycles: newCycles); + + for (final cycle in newCycles) { + // A cycle expires when any of its transitive deps expires, because if + // it gets a new dep that leads back to the cycle then that whole path + // joins the cycle. Get this expirey phase from the graph built by + // `_buildGraphs`. + final expiresAfter = + _graphs[cycle.ids.first]! + .expiringValueAt(phase: phase) + .expiresAfter; + + // Merge the computed cycle into any existing phased value for each ID. + // The phased value can differ by ID: they are in the same cycle at this + // phase, but might not have been in the same cycle earlier. + // + // Nevertheless, the case in which the phased values are the same is a + // common one, so use a temporary map from old value to new value to + // avoid creating many equal but not identical phased values. + final updatedValueByOldValue = + Map< + PhasedValue?, + PhasedValue + >.identity(); + + for (final id in cycle.ids) { + final existingCycle = _cycles[id]; + _cycles[id] = updatedValueByOldValue.putIfAbsent(existingCycle, () { + if (existingCycle == null) { + return PhasedValue.of(cycle, expiresAfter: expiresAfter); + } + return existingCycle.followedBy( + ExpiringValue(cycle, expiresAfter: expiresAfter), + ); + }); + } + } + } + } + + /// Builds [_graphs] at [phase] from [newCycles]. + /// + /// [newCycles] must be ordered so that a cycle is preceded by all its + /// dependencies. Fortunately, [stronglyConnectedComponents] already returns + /// cycles in that order. + /// + /// A [_graphs] entry will be created for each ID in [newCycles]. + void _buildGraphs(int phase, {required List newCycles}) { + // Build lookup from ID to [LibraryCycle] including new and existing cycles. + final existingCycles = []; + for (final phasedCycle in _cycles.values) { + if (phasedCycle.isExpiredAt(phase: phase)) continue; + existingCycles.add(phasedCycle.valueAt(phase: phase)); + } + final cycleById = {}; + for (final cycle in existingCycles) { + for (final id in cycle.ids) { + cycleById[id] = cycle; + } + } + for (final cycle in newCycles) { + for (final id in cycle.ids) { + cycleById[id] = cycle; + } + } + + // Create the graph for each cycle in [newCycles]. + for (final root in newCycles) { + final graph = LibraryCycleGraphBuilder()..root.replace(root); + + // The graph expires when any asset in the graph expires. Start this + //calculation by finding the earliest expirey phase of all assets in the + //graph root cycle. It will be updated for each child graph below. + var expiresAfter = root.ids + .map( + (id) => _assetDeps[id]!.expiringValueAt(phase: phase).expiresAfter, + ) + .reduce(earliestPhase); + + // Look up child cycles based on individual id dependencies, then look + // up graphs for those cycles. All child graphs have already been computed + // because of the order of [newCycles]. + final alreadyAddedChildren = Set.identity(); + for (final id in root.ids) { + final assetDeps = _assetDeps[id]!.valueAt(phase: phase); + for (final dep in assetDeps.deps) { + final depCycle = cycleById[dep]!; + if (identical(depCycle, root)) continue; + if (alreadyAddedChildren.add(depCycle)) { + final childGraph = _graphs[dep]!.expiringValueAt(phase: phase); + graph.children.add(childGraph.value); + expiresAfter = earliestPhase(expiresAfter, childGraph.expiresAfter); + } + } + } + + // If the graph expires, mark it for computation later. + if (expiresAfter != null) { + (_graphsToComputeByPhase[expiresAfter + 1] ??= {}).addAll(root.ids); + } + + // Merge the computed graph into any existing phased value for each ID + // in the root cycle. + // + // The phased value can differ by ID: they are in the same cycle at this + // phase, but might not have been in the same cycle earlier. + // + // Nevertheless, the case in which the phased values are the same is a + // common one, so use a temporary map from old value to new value to + // avoid creating many equal but not identical phased values. + final updatedValueByOldValue = + Map< + PhasedValue?, + PhasedValue + >.identity(); + + for (final idToUpdate in root.ids) { + final oldValue = _graphs[idToUpdate]; + _graphs[idToUpdate] = updatedValueByOldValue.putIfAbsent(oldValue, () { + if (oldValue == null) { + return PhasedValue.of(graph.build(), expiresAfter: expiresAfter); + } + return oldValue.followedBy( + ExpiringValue(graph.build(), expiresAfter: expiresAfter), + ); + }); + } + } + } + + /// Returns the [LibraryCycle] of [id] at all phases before the + /// [assetDepsLoader] phase. + /// + /// Previously computed state is used if possible, anything additional is + /// loaded using [assetDepsLoader]. + Future> libraryCycleOf( + AssetDepsLoader assetDepsLoader, + AssetId id, + ) async { + await _load(assetDepsLoader, id); + _buildCycles(assetDepsLoader.phase); + return _cycles[id]!; + } + + /// Returns the [LibraryCycleGraph] of [id] at all phases before the + /// [assetDepsLoader] phase. + /// + /// Previously computed state is used if possible, anything additional is + /// loaded using [assetDepsLoader]. + Future> libraryCycleGraphOf( + AssetDepsLoader assetDepsLoader, + AssetId id, + ) async { + await libraryCycleOf(assetDepsLoader, id); + return _graphs[id]!; + } + + /// Returns the transitive dependencies of Dart source [id] at the + /// [assetDepsLoader] phase. + /// + /// A "dependency" is a mention in `import`, `export`, `part` or `part of`. + /// Dependencies are considered at the [assetDepsLoader] phase, meaning that + /// files generated in that phase or later count as empty and have no deps. + /// + /// Note that sources generated _at_ the [assetDepsLoader] phase are + /// not readable during the phase and are not used. + /// + /// Previously computed state is used if possible, anything additional is + /// loaded using [assetDepsLoader]. + Future> transitiveDepsOf( + AssetDepsLoader assetDepsLoader, + AssetId id, + ) async { + final graph = await libraryCycleGraphOf(assetDepsLoader, id); + return graph.valueAt(phase: assetDepsLoader.phase).transitiveDeps; + } + + @override + String toString() => ''' +LibraryCycleGraphLoader( + _assetDeps: $_assetDeps, + _assetDepsToLoadByPhase: $_assetDepsToLoadByPhase, + _newAssets: $_newAssets, + _cycles: $_cycles, + _graphs: $_graphs, + _graphsToComputeByPhase: $_graphsToComputeByPhase, +)'''; +} diff --git a/build/lib/src/library_cycle_graph/phased_reader.dart b/build/lib/src/library_cycle_graph/phased_reader.dart new file mode 100644 index 000000000..f8b8e5c6b --- /dev/null +++ b/build/lib/src/library_cycle_graph/phased_reader.dart @@ -0,0 +1,32 @@ +// 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 '../asset/id.dart'; +import 'phased_value.dart'; + +/// Asset reader that views the build at one specific phase. +/// +/// In addition to asset contents it returns information about when an asset was +/// generated or will be generated. +abstract class PhasedReader { + /// The phase at which this reader sees the build. + int get phase; + + /// Reads [id] as a [PhasedValue]. + /// + /// If the asset is missing, returns a [PhasedValue.fixed] with an empty + /// string. + /// + /// If the asset is a source file, returns a [PhasedValue.fixed] with its + /// content. + /// + /// If the asset is generated, but has not yet been generated at [phase], + /// returns a [PhasedValue.unavailable] saying when it will be generated. + /// + /// If the asset is generated and _has_ already been generated, returns + /// a [PhasedValue.generated] specifying both when it was generated and + /// its content. Note that generation might output nothing, in which case an + /// empty string is returned for its content. + Future> readPhased(AssetId id); +} diff --git a/build/lib/src/library_cycle_graph/phased_value.dart b/build/lib/src/library_cycle_graph/phased_value.dart new file mode 100644 index 000000000..c659c7b9a --- /dev/null +++ b/build/lib/src/library_cycle_graph/phased_value.dart @@ -0,0 +1,172 @@ +// 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'; + +part 'phased_value.g.dart'; + +/// A value that changes during the build, according to the `int` build phase. +/// +/// The initial state of the build is at phase 0. At every phase after that, +/// files can be generated, causing the old value to _expire_. The new value +/// is available in the _next_ phase. +/// +/// For example, if `foo.dart` is a generated file generated in phase 3: +/// +/// - the value of `foo.dart` at phase 0 is empty/missing; +/// - that value expires after phase 3 +/// - the new value of `foo.dart` is readable in phase 4 +/// +/// Ignoring post process deletion, which happens outside the main build, a +/// single file can change at most once during the build: when a generated file +/// is generated. But a _set_ of files can change more than once, since files in +/// the set can change at different phases. So, any number of changes is +/// supported, allowing a `PhasedValue` to model multi-asset entities such as +/// dependency trees. +/// +/// Represented as a list of [ExpiringValue] with ascending +/// [ExpiringValue.expiresAfter]. +/// +/// If the last value in the list has non-null [ExpiringValue.expiresAfter] then +/// the `PhasedValue` is incomplete: after the specified phase it changes to an +/// unknown value. Or, if the last value in the list has `null` `expiresAfter` +/// then the `PhasedValue` is complete; no further changes are possible. +/// +/// A `PhasedValue` cannot have missing values before present values: the +/// initial value is always known, and the value after all changes except +/// possibly the last. +/// +/// TODO(davidmorgan): it might be more efficient to represent the simpler +/// cases, fixed or changing exactly once, as different implementation types. +abstract class PhasedValue + implements Built, PhasedValueBuilder> { + BuiltList> get values; + + factory PhasedValue([void Function(PhasedValueBuilder)? updates]) = + _$PhasedValue; + PhasedValue._(); + + /// A fixed [value] with no changes. + factory PhasedValue.fixed(T value) => PhasedValue((b) { + b.values.add(ExpiringValue(value)); + }); + + /// A value that will be generated during [untilAfterPhase]. + /// + /// Pass the "missing" value for T as [before]. + factory PhasedValue.unavailable({ + required int untilAfterPhase, + required T before, + }) => PhasedValue((b) { + b.values.add(ExpiringValue(before, expiresAfter: untilAfterPhase)); + }); + + /// A value that is generated during [atPhase], changing from [before] to + /// [value]. + factory PhasedValue.generated( + T value, { + required int atPhase, + required T before, + }) => PhasedValue((b) { + b.values.add(ExpiringValue(before, expiresAfter: atPhase)); + b.values.add(ExpiringValue(value)); + }); + + /// A [value] expiring after [expiresAfter] if it's not `null`. + factory PhasedValue.of(T value, {required int? expiresAfter}) => + PhasedValue((b) { + b.values.add(ExpiringValue(value, expiresAfter: expiresAfter)); + }); + + /// Whether this value is complete: all values are known, no further changes + /// are possible. + bool get isComplete => values.last.expiresAfter == null; + + /// The phase after which the value expires, or `null` if it never expires. + int? get expiresAfter => values.last.expiresAfter; + + /// Whether this value has expired at the specified [phase], meaning the + /// actual value is not known. + bool isExpiredAt({required int phase}) { + return expiresAfter != null && expiresAfter! < phase; + } + + /// The value at [phase], with its expirey phase. + /// + /// Throws `StateError` if the value has expired at [phase], meaning the value + /// is not known. + ExpiringValue expiringValueAt({required int phase}) { + for (final value in values) { + if (value.expiresAfter == null || value.expiresAfter! >= phase) { + return value; + } + } + throw StateError('No value for phase $phase in $this.'); + } + + /// The value at [phase]. + /// + /// Throws `StateError` if the value has expired at [phase], meaning the value + /// is not known. + T valueAt({required int phase}) => expiringValueAt(phase: phase).value; + + /// The value after all changes have happened. + /// + /// Throws if not [isComplete], meaning the last value is not known. + T get lastValue { + if (!isComplete) throw StateError('Not complete, no last value: $this'); + return values.last.value; + } + + /// This value followed by [value]. + /// + /// Throws `StateError` if [isComplete], as a complete value cannot change. + /// + /// Throws `StateError` if the additional value expires before or at + /// [expiresAfter], as it cannot follow this one. + PhasedValue followedBy(ExpiringValue value) { + if (values.last.expiresAfter == null) { + throw StateError("Can't follow a value that doesn't expire."); + } + if (value.expiresAfter != null && + value.expiresAfter! <= values.last.expiresAfter!) { + throw StateError( + "Can't follow with a value expiring before or at the existing value." + ' This: $this, followedBy: $value', + ); + } + return rebuild((b) { + b.values.add(value); + }); + } +} + +/// A [value] with optionally limited lifespan. +/// +/// If [expiresAfter] is `null`, the value never expires. +/// +/// If [expiresAfter] is set, the value expires after that phase, taking a new +/// value in the next phase. +abstract class ExpiringValue + implements Built, ExpiringValueBuilder> { + T get value; + int? get expiresAfter; + + factory ExpiringValue(T value, {int? expiresAfter}) => + _$ExpiringValue._(value: value, expiresAfter: expiresAfter); + ExpiringValue._(); +} + +/// Returns the earliest of two nullable phases [a] and [b]. +/// +/// `null` represents "never", so any non-`null` phase is earlier than a `null` +/// one. +int? earliestPhase(int? a, int? b) { + if (a == null) return b; + if (b == null) return a; + return min(a, b); +} diff --git a/build/lib/src/library_cycle_graph/phased_value.g.dart b/build/lib/src/library_cycle_graph/phased_value.g.dart new file mode 100644 index 000000000..66c844c5d --- /dev/null +++ b/build/lib/src/library_cycle_graph/phased_value.g.dart @@ -0,0 +1,213 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'phased_value.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$PhasedValue extends PhasedValue { + @override + final BuiltList> values; + + factory _$PhasedValue([void Function(PhasedValueBuilder)? updates]) => + (new PhasedValueBuilder()..update(updates))._build(); + + _$PhasedValue._({required this.values}) : super._() { + BuiltValueNullFieldError.checkNotNull(values, r'PhasedValue', 'values'); + if (T == dynamic) { + throw new BuiltValueMissingGenericsError(r'PhasedValue', 'T'); + } + } + + @override + PhasedValue rebuild(void Function(PhasedValueBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + PhasedValueBuilder toBuilder() => + new PhasedValueBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PhasedValue && values == other.values; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, values.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PhasedValue') + ..add('values', values)).toString(); + } +} + +class PhasedValueBuilder + implements Builder, PhasedValueBuilder> { + _$PhasedValue? _$v; + + ListBuilder>? _values; + ListBuilder> get values => + _$this._values ??= new ListBuilder>(); + set values(ListBuilder>? values) => _$this._values = values; + + PhasedValueBuilder(); + + PhasedValueBuilder get _$this { + final $v = _$v; + if ($v != null) { + _values = $v.values.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(PhasedValue other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$PhasedValue; + } + + @override + void update(void Function(PhasedValueBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PhasedValue build() => _build(); + + _$PhasedValue _build() { + _$PhasedValue _$result; + try { + _$result = _$v ?? new _$PhasedValue._(values: values.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'values'; + values.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'PhasedValue', + _$failedField, + e.toString(), + ); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +class _$ExpiringValue extends ExpiringValue { + @override + final T value; + @override + final int? expiresAfter; + + factory _$ExpiringValue([void Function(ExpiringValueBuilder)? updates]) => + (new ExpiringValueBuilder()..update(updates))._build(); + + _$ExpiringValue._({required this.value, this.expiresAfter}) : super._() { + BuiltValueNullFieldError.checkNotNull(value, r'ExpiringValue', 'value'); + if (T == dynamic) { + throw new BuiltValueMissingGenericsError(r'ExpiringValue', 'T'); + } + } + + @override + ExpiringValue rebuild(void Function(ExpiringValueBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ExpiringValueBuilder toBuilder() => + new ExpiringValueBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ExpiringValue && + value == other.value && + expiresAfter == other.expiresAfter; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, value.hashCode); + _$hash = $jc(_$hash, expiresAfter.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ExpiringValue') + ..add('value', value) + ..add('expiresAfter', expiresAfter)) + .toString(); + } +} + +class ExpiringValueBuilder + implements Builder, ExpiringValueBuilder> { + _$ExpiringValue? _$v; + + T? _value; + T? get value => _$this._value; + set value(T? value) => _$this._value = value; + + int? _expiresAfter; + int? get expiresAfter => _$this._expiresAfter; + set expiresAfter(int? expiresAfter) => _$this._expiresAfter = expiresAfter; + + ExpiringValueBuilder(); + + ExpiringValueBuilder get _$this { + final $v = _$v; + if ($v != null) { + _value = $v.value; + _expiresAfter = $v.expiresAfter; + _$v = null; + } + return this; + } + + @override + void replace(ExpiringValue other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$ExpiringValue; + } + + @override + void update(void Function(ExpiringValueBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ExpiringValue build() => _build(); + + _$ExpiringValue _build() { + final _$result = + _$v ?? + new _$ExpiringValue._( + value: BuiltValueNullFieldError.checkNotNull( + value, + r'ExpiringValue', + 'value', + ), + expiresAfter: expiresAfter, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/build/pubspec.yaml b/build/pubspec.yaml index f9a47ea1f..f45bd1f95 100644 --- a/build/pubspec.yaml +++ b/build/pubspec.yaml @@ -11,9 +11,12 @@ dependencies: analyzer: '>=6.9.0 <8.0.0' async: ^2.5.0 build_runner_core: ^9.0.0-wip + built_collection: ^5.1.1 + built_value: ^8.9.5 convert: ^3.0.0 crypto: ^3.0.0 glob: ^2.0.0 + graphs: ^2.2.0 logging: ^1.0.0 meta: ^1.3.0 package_config: ^2.1.0 @@ -23,6 +26,7 @@ dependencies: dev_dependencies: build_resolvers: ^2.4.0 build_test: ^3.0.0-wip + built_value_generator: ^8.9.5 dart_flutter_team_lints: ^3.1.0 test: ^1.16.0 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 new file mode 100644 index 000000000..5f344b77d --- /dev/null +++ b/build/test/library_cycle_graph/library_cycle_graph_loader_test.dart @@ -0,0 +1,488 @@ +// 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:build/src/asset/id.dart'; +import 'package:build/src/library_cycle_graph/asset_deps.dart'; +import 'package:build/src/library_cycle_graph/asset_deps_loader.dart'; +import 'package:build/src/library_cycle_graph/library_cycle.dart'; +import 'package:build/src/library_cycle_graph/library_cycle_graph.dart'; +import 'package:build/src/library_cycle_graph/library_cycle_graph_loader.dart'; +import 'package:build/src/library_cycle_graph/phased_value.dart'; +import 'package:test/test.dart'; + +void main() { + final a1 = AssetId('a', '1'); + final a2 = AssetId('a', '2'); + final a3 = AssetId('a', '3'); + final a4 = AssetId('a', '4'); + final a5 = AssetId('a', '5'); + final a6 = AssetId('a', '6'); + final a7 = AssetId('a', '7'); + final a8 = AssetId('a', '8'); + final a9 = AssetId('a', '9'); + + group('LibraryCycleGraphLoader', () { + group('no generated nodes', () { + test('single missing node', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps.empty), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1}); + }); + + test('single present node', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1}); + }); + + test('node with one dep', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({a2})), + a2: PhasedValue.fixed(AssetDeps({})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1, a2}); + }); + + test('seven node tree', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({a2, a3})), + a2: PhasedValue.fixed(AssetDeps({a4, a5})), + a3: PhasedValue.fixed(AssetDeps({a6, a7})), + a4: PhasedValue.fixed(AssetDeps({})), + a5: PhasedValue.fixed(AssetDeps({})), + a6: PhasedValue.fixed(AssetDeps({})), + a7: PhasedValue.fixed(AssetDeps({})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), { + a1, + a2, + a3, + a4, + a5, + a6, + a7, + }); + }); + + test('four node diamond', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({a2, a3})), + a2: PhasedValue.fixed(AssetDeps({a4})), + a3: PhasedValue.fixed(AssetDeps({a4})), + a4: PhasedValue.fixed(AssetDeps({})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1, a2, a3, a4}); + }); + + test('two node cycle', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({a2})), + a2: PhasedValue.fixed(AssetDeps({a1})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1, a2}); + + expect( + (await loader.libraryCycleOf(nodeLoader, a1)).valueAt(phase: 0).ids, + {a1, a2}, + ); + }); + + test('two node cycle excluding entrypoint', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({a2})), + a2: PhasedValue.fixed(AssetDeps({a3})), + a3: PhasedValue.fixed(AssetDeps({a2})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1, a2, a3}); + + expect( + (await loader.libraryCycleOf(nodeLoader, a1)).valueAt(phase: 0).ids, + {a1}, + ); + expect( + (await loader.libraryCycleOf(nodeLoader, a2)).valueAt(phase: 0).ids, + {a2, a3}, + ); + }); + }); + + test('phased cycle uses same instance', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({a2})), + a2: PhasedValue.fixed(AssetDeps({a3})), + a3: PhasedValue.fixed(AssetDeps({a2})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1, a2, a3}); + + expect( + await loader.libraryCycleOf(nodeLoader, a2), + same(await loader.libraryCycleOf(nodeLoader, a3)), + ); + }); + + test('phased graph uses same instance', () async { + final nodeLoader = TestAssetDepsLoader(0, { + a1: PhasedValue.fixed(AssetDeps({a2})), + a2: PhasedValue.fixed(AssetDeps({a3})), + a3: PhasedValue.fixed(AssetDeps({a2})), + }); + final loader = LibraryCycleGraphLoader(); + expect(await loader.transitiveDepsOf(nodeLoader, a1), {a1, a2, a3}); + + expect( + await loader.libraryCycleGraphOf(nodeLoader, a2), + same(await loader.libraryCycleGraphOf(nodeLoader, a3)), + ); + }); + }); + + group('with generated nodes', () { + test('single generated node', () async { + final nodeLoader0 = TestAssetDepsLoader(0, { + a1: PhasedValue.unavailable( + untilAfterPhase: 1, + before: AssetDeps.empty, + ), + }); + final nodeLoader2 = TestAssetDepsLoader(2, { + a1: PhasedValue.generated( + atPhase: 1, + before: AssetDeps.empty, + AssetDeps({}), + ), + }); + final loader = LibraryCycleGraphLoader(); + + // Load before it's generated; the not-yet-available node is still a dep. + // But the results show that they are incomplete. + expect(await loader.transitiveDepsOf(nodeLoader0, a1), {a1}); + expect((await loader.libraryCycleOf(nodeLoader0, a1)).expiresAfter, 1); + expect( + (await loader.libraryCycleGraphOf(nodeLoader0, a1)).expiresAfter, + 1, + ); + + // Same result, but now marked as complete. + expect(await loader.transitiveDepsOf(nodeLoader2, a1), {a1}); + expect( + (await loader.libraryCycleOf(nodeLoader0, a1)).expiresAfter, + isNull, + ); + expect( + (await loader.libraryCycleGraphOf(nodeLoader0, a1)).expiresAfter, + isNull, + ); + }); + + group('sequence of three nodes', () { + final nodeLoader4 = TestAssetDepsLoader(4, { + a1: PhasedValue.generated( + atPhase: 1, + before: AssetDeps.empty, + AssetDeps({a2}), + ), + a2: PhasedValue.generated( + atPhase: 2, + before: AssetDeps.empty, + AssetDeps({a3}), + ), + a3: PhasedValue.generated( + atPhase: 3, + before: AssetDeps.empty, + AssetDeps({}), + ), + }); + final nodeLoader3 = nodeLoader4.at(phase: 3); + final nodeLoader2 = nodeLoader4.at(phase: 2); + final nodeLoader1 = nodeLoader4.at(phase: 1); + + test('loaded in the order they appear', () async { + final loader = LibraryCycleGraphLoader(); + + expect(await loader.transitiveDepsOf(nodeLoader1, a1), {a1}); + expect(await loader.transitiveDepsOf(nodeLoader2, a1), {a1, a2}); + expect(await loader.transitiveDepsOf(nodeLoader3, a1), {a1, a2, a3}); + expect(await loader.transitiveDepsOf(nodeLoader4, a1), {a1, a2, a3}); + }); + + test('loaded in reverse order', () async { + final loader = LibraryCycleGraphLoader(); + + expect(await loader.transitiveDepsOf(nodeLoader4, a1), {a1, a2, a3}); + expect(await loader.transitiveDepsOf(nodeLoader3, a1), {a1, a2, a3}); + expect(await loader.transitiveDepsOf(nodeLoader2, a1), {a1, a2}); + expect(await loader.transitiveDepsOf(nodeLoader1, a1), {a1}); + }); + }); + + group('graph of nine nodes', () { + // A graph with 9 nodes generated in phases 1-5. + // + // a1 and a2 are generated in phase 1, then a3-a5 in phase 2, a6-7 in + // phase 3, a8 in phase 4 and a9 in phase 5; in the ASCII art diagram, + // nodes on the same vertical line are generated in the same phase. + // + // Notes: + // + // - a node is readable the phase _after_ it's generated + // - a node can be depended on _before_ it's generated or readable + // - node a3 has a self edge not shown in the ASCII art diagram. + // + // ``` + // ------------------\ + // / \ + // v v---------\ \ + // a1 --> a3 -------> a6 | + // | | + // \-------> a7 | + // | | + // a2 --> a4 <-------/ | + // / ^ | | + // | | v | + // | \---- a5 | + // | | + // \---------------------> a8 <--- a9 + // ``` + + final nodeLoader = TestAssetDepsLoader(5, { + a1: PhasedValue.generated( + atPhase: 1, + before: AssetDeps.empty, + AssetDeps({a3}), + ), + a2: PhasedValue.generated( + atPhase: 1, + before: AssetDeps.empty, + AssetDeps({a4, a8}), + ), + a3: PhasedValue.generated( + atPhase: 2, + before: AssetDeps.empty, + AssetDeps({a3, a6, a7}), + ), + a4: PhasedValue.generated( + atPhase: 2, + before: AssetDeps.empty, + AssetDeps({a5}), + ), + a5: PhasedValue.generated( + atPhase: 2, + before: AssetDeps.empty, + AssetDeps({a2}), + ), + a6: PhasedValue.generated( + atPhase: 3, + before: AssetDeps.empty, + AssetDeps({a3}), + ), + a7: PhasedValue.generated( + atPhase: 3, + before: AssetDeps.empty, + AssetDeps({a4}), + ), + a8: PhasedValue.generated( + atPhase: 4, + before: AssetDeps.empty, + AssetDeps({a1}), + ), + a9: PhasedValue.generated( + atPhase: 5, + before: AssetDeps.empty, + AssetDeps({a8}), + ), + }); + + // Test expectations as data so they can be checked in + // different orders, exercising the "lazy" nature of the loader. + // The transitive deps expected from each node at each phase. + final expectations = [ + Expect(phase: 1, from: a1, deps: {a1}), + Expect(phase: 1, from: a2, deps: {a2}), + Expect(phase: 1, from: a3, deps: {a3}), + Expect(phase: 1, from: a4, deps: {a4}), + Expect(phase: 1, from: a5, deps: {a5}), + Expect(phase: 1, from: a6, deps: {a6}), + Expect(phase: 1, from: a7, deps: {a7}), + Expect(phase: 1, from: a8, deps: {a8}), + Expect(phase: 1, from: a9, deps: {a9}), + Expect(phase: 2, from: a1, deps: {a1, a3}), + Expect(phase: 2, from: a2, deps: {a2, a4, a8}), + Expect(phase: 2, from: a3, deps: {a3}), + Expect(phase: 2, from: a4, deps: {a4}), + Expect(phase: 2, from: a5, deps: {a5}), + Expect(phase: 2, from: a6, deps: {a6}), + Expect(phase: 2, from: a7, deps: {a7}), + Expect(phase: 2, from: a8, deps: {a8}), + Expect(phase: 2, from: a9, deps: {a9}), + Expect(phase: 3, from: a1, deps: {a1, a3, a6, a7}), + Expect(phase: 3, from: a2, deps: {a2, a4, a5, a8}), + Expect(phase: 3, from: a3, deps: {a3, a6, a7}), + Expect(phase: 3, from: a4, deps: {a2, a4, a5, a8}), + Expect(phase: 3, from: a5, deps: {a2, a4, a5, a8}), + Expect(phase: 3, from: a6, deps: {a6}), + Expect(phase: 3, from: a7, deps: {a7}), + Expect(phase: 3, from: a8, deps: {a8}), + Expect(phase: 3, from: a9, deps: {a9}), + Expect(phase: 4, from: a1, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 4, from: a2, deps: {a2, a4, a8, a5}), + Expect(phase: 4, from: a3, deps: {a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 4, from: a4, deps: {a2, a4, a5, a8}), + Expect(phase: 4, from: a5, deps: {a2, a4, a5, a8}), + Expect(phase: 4, from: a6, deps: {a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 4, from: a7, deps: {a2, a4, a5, a7, a8}), + Expect(phase: 4, from: a8, deps: {a8}), + Expect(phase: 4, from: a9, deps: {a9}), + Expect(phase: 5, from: a1, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a2, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a3, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a4, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a5, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a6, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a7, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a8, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 5, from: a9, deps: {a9}), + Expect(phase: 6, from: a1, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a2, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a3, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a4, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a5, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a6, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a7, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a8, deps: {a1, a2, a3, a4, a5, a6, a7, a8}), + Expect(phase: 6, from: a9, deps: {a1, a2, a3, a4, a5, a6, a7, a8, a9}), + ]; + + /// Checks that loaded cycles and graphs that are equal are also + /// identical. + /// + /// This is important for efficiency so that later processing, such as + /// serialization, can deduplicate work by identity. + Future expectEqualValuesAreIdentical( + LibraryCycleGraphLoader loader, + ) async { + final allCycles = Set.identity(); + final allGraphs = Set.identity(); + for (final id in [a1, a2, a3, a4, a5, a6, a7, a8, a9]) { + final phasedCycle = await loader.libraryCycleOf(nodeLoader, id); + final phasedGraph = await loader.libraryCycleGraphOf(nodeLoader, id); + 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); + } + } + + expect(allCycles.length, Set.of(allCycles).length); + expect(allGraphs.length, Set.of(allGraphs).length); + } + + test('loaded in order', () async { + final loader = LibraryCycleGraphLoader(); + for (final expectation in expectations) { + printOnFailure(expectation.toString()); + await expectation.run(loader, nodeLoader); + } + await expectEqualValuesAreIdentical(loader); + }); + + test('loaded in reverse order', () async { + final loader = LibraryCycleGraphLoader(); + for (final expectation in expectations.reversed) { + printOnFailure(expectation.toString()); + await expectation.run(loader, nodeLoader); + } + await expectEqualValuesAreIdentical(loader); + }); + + // 50 randomized runs was enough to find all known issues multiple times; + // 100000 runs, which only takes 100s, didn't find any further issues. + for (var seed = 0; seed != 50; ++seed) { + test('loaded in random order (seed $seed)', () async { + final random = Random(seed); + final loader = LibraryCycleGraphLoader(); + for (final expectation in expectations.toList()..shuffle(random)) { + printOnFailure(expectation.toString()); + await expectation.run(loader, nodeLoader); + } + await expectEqualValuesAreIdentical(loader); + }); + } + }); + }); +} + +/// An [AssetDepsLoader] with data passed in from test setup. +class TestAssetDepsLoader implements AssetDepsLoader { + @override + final int phase; + final Map> results; + + TestAssetDepsLoader(this.phase, this.results); + + @override + Future> load(AssetId id) async { + return results[id]!; + } + + /// Returns a [TestAssetDepsLoader] at [phase] with the same values as this, + /// but excluding any values not yet available at [phase]. + TestAssetDepsLoader at({required int phase}) { + return TestAssetDepsLoader( + phase, + results.map((id, value) { + return MapEntry( + id, + PhasedValue((b) { + for (final expiringValue in value.values) { + b.values.add(expiringValue); + if (expiringValue.expiresAfter != null && + expiringValue.expiresAfter! > phase) { + return; + } + } + }), + ); + }), + ); + } +} + +/// An expectation about [LibraryCycleGraphLoader#transitiveDepsOf]. +class Expect { + /// The phase the deps are evaluated in. + final int phase; + + /// The asset that is the starting point for `transitiveDepsOf`. + final AssetId from; + + /// The expected transitive deps. + final Iterable deps; + + Expect({required this.phase, required this.from, required this.deps}); + + Future run( + LibraryCycleGraphLoader loader, + TestAssetDepsLoader nodeLoader, + ) async { + expect( + await loader.transitiveDepsOf(nodeLoader.at(phase: phase), from), + deps, + reason: toString(), + ); + } + + @override + String toString() => 'Transitive deps of $from at phase $phase.'; +} diff --git a/build_resolvers/CHANGELOG.md b/build_resolvers/CHANGELOG.md index 592131ba2..3527d3db1 100644 --- a/build_resolvers/CHANGELOG.md +++ b/build_resolvers/CHANGELOG.md @@ -8,6 +8,7 @@ algorithm, preventing stack overflows. - Move `BuildStepImpl` to `build_runner_core`, use `SingleStepReader` directly. - Stop building `transitive_digest` files by default. +- Use `LibraryCycleGraphLoader` to load transitive deps for analysis. ## 2.4.4 diff --git a/build_resolvers/lib/src/analysis_driver_model.dart b/build_resolvers/lib/src/analysis_driver_model.dart index 55db8be5f..98e298f58 100644 --- a/build_resolvers/lib/src/analysis_driver_model.dart +++ b/build_resolvers/lib/src/analysis_driver_model.dart @@ -3,14 +3,13 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; -import 'dart:collection'; -import 'package:analyzer/dart/analysis/utilities.dart'; -import 'package:analyzer/dart/ast/ast.dart'; // ignore: implementation_imports import 'package:analyzer/src/clients/build_resolvers/build_resolvers.dart'; import 'package:build/build.dart'; // ignore: implementation_imports +import 'package:build/src/internal.dart'; +// ignore: implementation_imports import 'package:build_runner_core/src/generate/build_step_impl.dart'; import 'analysis_driver_filesystem.dart'; @@ -34,7 +33,7 @@ class AnalysisDriverModel { final AnalysisDriverFilesystem filesystem = AnalysisDriverFilesystem(); /// The import graph of all sources needed for analysis. - final _graph = _Graph(); + final LibraryCycleGraphLoader _graphLoader = LibraryCycleGraphLoader(); /// Assets that have been synced into the in-memory filesystem /// [filesystem]. @@ -49,7 +48,7 @@ class AnalysisDriverModel { /// Clear cached information specific to an individual build. void reset() { - _graph.clear(); + _graphLoader.clear(); _syncedOntoFilesystem.clear(); } @@ -70,9 +69,9 @@ class AnalysisDriverModel { } /// Updates [filesystem] and the analysis driver given by - /// `withDriverResource` with updated versions of [entryPoints]. + /// `withDriverResource` with updated versions of [entrypoints]. /// - /// If [transitive], then all the transitive imports from [entryPoints] are + /// If [transitive], then all the transitive imports from [entrypoints] are /// also updated. /// /// Notifies [buildStep] of all inputs that result from analysis. If @@ -80,7 +79,7 @@ class AnalysisDriverModel { /// Future performResolve( BuildStep buildStep, - List entryPoints, + List entrypoints, Future Function( FutureOr Function(AnalysisDriverForPackageBuild), ) @@ -88,38 +87,44 @@ class AnalysisDriverModel { required bool transitive, }) async { // Immediately take the lock on `driver` so that the whole class state, - // `_graph` and `_readForAnalyzir`, is only mutated by one build step at a - // time. Otherwise, interleaved access complicates processing significantly. + // is only mutated by one build step at a time. await withDriverResource((driver) async { - return _performResolve( - driver, - buildStep as BuildStepImpl, - entryPoints, - withDriverResource, - transitive: transitive, - ); + // TODO(davidmorgan): it looks like this is only ever called with a single + // entrypoint, consider doing a breaking release to simplify the API. + for (final entrypoint in entrypoints) { + await _performResolve( + driver, + buildStep as BuildStepImpl, + entrypoint, + withDriverResource, + transitive: transitive, + ); + } }); } Future _performResolve( AnalysisDriverForPackageBuild driver, BuildStepImpl buildStep, - List entryPoints, + AssetId entrypoint, Future Function( FutureOr Function(AnalysisDriverForPackageBuild), ) withDriverResource, { required bool transitive, }) async { - var idsToSyncOntoFilesystem = entryPoints; - Iterable inputIds = entryPoints; + var idsToSyncOntoFilesystem = [entrypoint]; + Iterable inputIds = [entrypoint]; // If requested, find transitive imports. if (transitive) { - final previouslyMissingFiles = await _graph.load(buildStep, entryPoints); - _syncedOntoFilesystem.removeAll(previouslyMissingFiles); - idsToSyncOntoFilesystem = _graph.nodes.keys.toList(); - inputIds = _graph.inputsFor(entryPoints); + final nodeLoader = AssetDepsLoader(buildStep.phasedReader); + idsToSyncOntoFilesystem = + (await _graphLoader.transitiveDepsOf( + nodeLoader, + entrypoint, + )).toList(); + inputIds = idsToSyncOntoFilesystem; } // Notify [buildStep] of its inputs. @@ -130,7 +135,9 @@ class AnalysisDriverModel { // Sync changes onto the "URI resolver", the in-memory filesystem. for (final id in idsToSyncOntoFilesystem) { - if (!_syncedOntoFilesystem.add(id)) continue; + if (!_syncedOntoFilesystem.add(id)) { + continue; + } final content = await buildStep.canRead(id) ? await buildStep.readAsString(id) : null; if (content == null) { @@ -150,119 +157,7 @@ class AnalysisDriverModel { } } -const _ignoredSchemes = ['dart', 'dart-ext']; - -/// Parses Dart source in [content], returns all depedencies: all assets -/// mentioned in directives, excluding `dart:` and `dart-ext` schemes. -List _parseDependencies(String content, AssetId from) => - parseString(content: content, throwIfDiagnostics: false).unit.directives - .whereType() - .map((directive) => directive.uri.stringValue) - // Uri.stringValue can be null for strings that use interpolation. - .nonNulls - .where( - (uriContent) => !_ignoredSchemes.any(Uri.parse(uriContent).isScheme), - ) - .map((content) => AssetId.resolve(Uri.parse(content), from: from)) - .toList(); - extension _AssetIdExtensions on AssetId { /// Asset path for the in-memory filesystem. String get asPath => AnalysisDriverFilesystem.assetPath(this); } - -/// The directive graph of all known sources. -class _Graph { - final Map nodes = {}; - - /// Walks the import graph from [ids] loading into [nodes]. - /// - /// Checks files that are in the graph as missing to determine whether they - /// are now available. - /// - /// Returns the set of files that were in the graph as missing and have now - /// been loaded. - Future> load(AssetReader reader, Iterable ids) async { - // TODO(davidmorgan): check if List is faster. - final nextIds = Queue.of(ids); - final processed = {}; - final previouslyMissingFiles = {}; - while (nextIds.isNotEmpty) { - final id = nextIds.removeFirst(); - - if (!processed.add(id)) continue; - - // Read nodes not yet loaded or that were missing when loaded. - var node = nodes[id]; - if (node == null || node.isMissing) { - if (await reader.canRead(id)) { - // If it was missing when loaded, record that. - if (node != null && node.isMissing) { - previouslyMissingFiles.add(id); - } - // Load the node. - final content = await reader.readAsString(id); - final deps = _parseDependencies(content, id); - node = _Node(id: id, deps: deps); - } else { - node ??= _Node.missing(id: id); - } - nodes[id] = node; - } - - // Continue to deps even for already-loaded nodes, to check missing files. - nextIds.addAll(node.deps.where((id) => !processed.contains(id))); - } - - return previouslyMissingFiles; - } - - void clear() { - nodes.clear(); - } - - /// The inputs for a build action analyzing [entryPoints]. - /// - /// This is transitive deps, but cut off by the presence of any - /// `.transitive_digest` file next to an asset. - Set inputsFor(Iterable entryPoints) { - final result = entryPoints.toSet(); - final nextIds = Queue.of(entryPoints); - - while (nextIds.isNotEmpty) { - final nextId = nextIds.removeFirst(); - final node = nodes[nextId]!; - - // Skip if there are no deps because the file is missing. - if (node.isMissing) continue; - - // For each dep, if it's not in `result` yet, it's newly-discovered: - // add it to `nextIds`. - for (final dep in node.deps) { - if (result.add(dep)) { - nextIds.add(dep); - } - } - } - return result; - } - - @override - String toString() => nodes.toString(); -} - -/// A node in the directive graph. -class _Node { - final AssetId id; - final List deps; - final bool isMissing; - - _Node({required this.id, required this.deps}) : isMissing = false; - - _Node.missing({required this.id}) : isMissing = true, deps = const []; - - @override - String toString() => - '$id:' - '${isMissing ? 'missing' : deps}'; -} diff --git a/build_runner_core/CHANGELOG.md b/build_runner_core/CHANGELOG.md index 04894d0b2..01ee2ab90 100644 --- a/build_runner_core/CHANGELOG.md +++ b/build_runner_core/CHANGELOG.md @@ -26,6 +26,7 @@ - New change detection algorithm. - Add `reportUnusedAssetsForInput` to `BuildOptions`, to listen for when a builder notifies that an asset is unused. +- Use `LibraryCycleGraphLoader` to load transitive deps for analysis. ## 8.0.0 diff --git a/build_runner_core/lib/src/generate/build_step_impl.dart b/build_runner_core/lib/src/generate/build_step_impl.dart index 5e1090f91..30360a64f 100644 --- a/build_runner_core/lib/src/generate/build_step_impl.dart +++ b/build_runner_core/lib/src/generate/build_step_impl.dart @@ -129,6 +129,10 @@ class BuildStepImpl implements BuildStep, AssetReaderState, AssetReaderWriter { Future? _resolver; + /// A reader for assets that additionally provides information about when an + /// asset was generated or will be generated. + PhasedReader get phasedReader => _readerWriter; + @override Future canRead(AssetId id) { if (_isComplete) throw BuildStepCompletedException(); 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 d2ea440f6..0301f69fb 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 @@ -95,7 +95,7 @@ class RunningBuildStep { /// Tracks the assets and globs read during this step for input dependency /// tracking. class SingleStepReaderWriter extends AssetReader - implements AssetReaderState, AssetReaderWriter { + implements AssetReaderState, AssetReaderWriter, PhasedReader { @override late final AssetFinder assetFinder = FunctionAssetFinder(_findAssets); @@ -203,6 +203,9 @@ class SingleStepReaderWriter extends AssetReader @override FilesystemCache get cache => _delegate.cache; + @override + int get phase => _runningBuildStep?.phaseNumber ?? 0; + /// Checks whether [id] can be read by this step - attempting to build the /// asset if necessary. /// @@ -419,4 +422,36 @@ class SingleStepReaderWriter extends AssetReader assetsWritten.add(id); return _delegate.writeAsString(id, contents, encoding: encoding); } + + @override + Future> readPhased(AssetId id) async { + if (_runningBuild == null) { + final exists = await _delegate.canRead(id); + if (exists) { + return PhasedValue.fixed(await _delegate.readAsString(id)); + } else { + return PhasedValue.fixed(''); + } + } + + final node = _runningBuild.assetGraph.get(id); + if (node == null) { + return PhasedValue.fixed(''); + } + + if (node.type == NodeType.generated) { + final nodePhase = node.generatedNodeConfiguration!.phaseNumber; + if (nodePhase >= phase) { + return PhasedValue.unavailable(before: '', untilAfterPhase: nodePhase); + } else { + return PhasedValue.generated( + atPhase: phase, + before: '', + await _delegate.readAsString(id), + ); + } + } + + return PhasedValue.fixed(await _delegate.readAsString(id)); + } }