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 c37fdb818..e75f69d42 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 @@ -451,7 +451,7 @@ class SingleStepReaderWriter extends AssetReader node = _runningBuild.assetGraph.get(id)!; } return PhasedValue.generated( - atPhase: phase, + atPhase: nodePhase, before: '', node.generatedNodeState!.isSuccessfulFreshOutput ? await _delegate.readAsString(id) diff --git a/build_runner_core/test/invalidation/invalidation_tester.dart b/build_runner_core/test/invalidation/invalidation_tester.dart index 7887eb4a5..943739542 100644 --- a/build_runner_core/test/invalidation/invalidation_tester.dart +++ b/build_runner_core/test/invalidation/invalidation_tester.dart @@ -21,6 +21,12 @@ class InvalidationTester { /// The source assets on disk before the first build. final Set _sourceAssets = {}; + /// Import statements. + /// + /// If an asset has an entry here then sources and generated sources will + /// start with the import statements specified. + final Map> _importGraph = {}; + /// The builders that will run. final List _builders = []; @@ -43,6 +49,9 @@ class InvalidationTester { /// The build number for "printOnFailure" output. int _buildNumber = 0; + /// Output number, for writing outputs that are different. + int _outputNumber = 0; + /// Sets the assets that will be on disk before the first build. /// /// See the note on "names" in the class dartdoc. @@ -53,6 +62,12 @@ class InvalidationTester { } } + // Sets the import graph for source files and generated files. + void importGraph(Map> importGraph) { + _importGraph.clear(); + _importGraph.addAll(importGraph); + } + /// Adds a builder to the test. /// /// [from] and [to] are the input and output extension of the builder, @@ -106,6 +121,13 @@ class InvalidationTester { _failureStrategies[name.assetId] = FailureStrategy.succeed; } + String _imports(AssetId id) { + final imports = _importGraph[_assetIdToName(id)]; + return imports == null + ? '' + : imports.map((i) => "import '${i.pathForImport}';").join('\n'); + } + /// Does a build. /// /// For the initial build, do not pass [change] [delete] or [create]. @@ -120,7 +142,7 @@ class InvalidationTester { throw StateError('Do a build without change, delete or create first.'); } for (final id in _sourceAssets) { - assets[id] = '// initial source'; + assets[id] = '${_imports(id)}// initial source'; } } else { // Create the new filesystem from the previous build state. @@ -131,7 +153,8 @@ class InvalidationTester { // Make the requested updates. if (change != null) { - assets[change.assetId] = '${assets[change.assetId]}\n// changed'; + assets[change.assetId] = + '${_imports(change.assetId)}}\n// ${++_outputNumber}'; } if (delete != null) { if (assets.containsKey(delete.assetId)) { @@ -148,7 +171,7 @@ class InvalidationTester { if (assets.containsKey(create.assetId)) { throw StateError('Asset $create to create already exists in: $assets'); } - assets[create.assetId] = '// initial source'; + assets[create.assetId] = '${_imports(create.assetId)}// initial source'; } // Build and check what changed. @@ -259,6 +282,11 @@ class TestBuilderBuilder { _builder.otherReads.add(name); } + /// Test setup: the builder will parse the Dart source asset with [name]. + void resolvesOther(String name) { + _builder.otherResolves.add(name); + } + /// Test setup: the builder will write the asset that is [extension] applied /// to the primary input. /// @@ -287,6 +315,9 @@ class TestBuilder implements Builder { /// Names of assets that the builder will read. List otherReads = []; + /// Names of assets that the builder will resolve. + List otherResolves = []; + /// Extensions of assets that the builder will write. /// /// The extensions are applied to the primary input asset ID with @@ -312,14 +343,21 @@ class TestBuilder implements Builder { content.add(await buildStep.readAsString(read.assetId)); } } + for (final resolve in otherResolves) { + content.add(resolve.assetId.toString()); + await buildStep.resolver.libraryFor(resolve.assetId); + } for (final write in writes) { final writeId = buildStep.inputId.replaceAllPathExtensions(write); final outputStrategy = _tester._outputStrategies[writeId] ?? OutputStrategy.inputDigest; + final inputHash = base64.encode( + md5.convert(utf8.encode(content.join('\n\n'))).bytes, + ); final output = switch (outputStrategy) { - OutputStrategy.fixed => '', + OutputStrategy.fixed => _tester._imports(writeId), OutputStrategy.inputDigest => - '// ${base64.encode(md5.convert(utf8.encode(content.join('\n\n'))).bytes)}', + '${_tester._imports(writeId)}\n// $inputHash', OutputStrategy.none => null, }; if (output != null) { @@ -354,6 +392,9 @@ extension StringExtension on String { AssetId get generatedAssetId => AssetId('pkg', '.dart_tool/build/generated/pkg/lib/$this.dart'); + /// Maps "names" to relative import path. + String get pathForImport => '$this.dart'; + /// Displays trimmed and with two space indent. String get trimAndIndent => ' ${toString().trim().replaceAll('\n', '\n ')}'; } diff --git a/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart b/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart new file mode 100644 index 000000000..db79e2521 --- /dev/null +++ b/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart @@ -0,0 +1,115 @@ +// 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'; + +/// In the test names: +/// +/// - `a1 <== a2` means a1 is the primary input of a2 _and_ is an input: +/// the builder _does_ read a1 +/// - dart source import graphs are introduced with "resolves", so +/// `a1 resolves: a2 --> a3 --> a4` means that the generation of a1 +/// resolves a2, which imports a3, which imports a4 +void main() { + late InvalidationTester tester; + + setUp(() { + tester = InvalidationTester(); + }); + + group('a.1 <== a.2, a.2 resolves: a.1 --> za --> zb', () { + setUp(() { + tester.sources(['a.1', 'za', 'zb']); + tester.importGraph({ + 'a.1': ['za'], + 'za': ['zb'], + }); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..resolvesOther('a.1') + ..writes('.2'); + }); + + test('a.2 is built', () async { + expect(await tester.build(), Result(written: ['a.2'])); + }); + + test('change za, a.2 is rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'za'), Result(written: ['a.2'])); + }); + + test('change zb, a.2 is rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'zb'), Result(written: ['a.2'])); + }); + }); + + // Transitive dependencies via files generated in earlier phases. + group('a.1 <== a.2, a.3 <== a.4, a.5 <== a.6, ' + 'a.6 resolves: z -> a.4 --> a.2', () { + setUp(() { + tester.sources(['a.1', 'a.3', 'a.5', 'z']); + tester.importGraph({ + 'z': ['a.4'], + 'a.4': ['a.2'], + }); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..writes('.2'); + tester.builder(from: '.3', to: '.4') + ..reads('.3') + ..writes('.4'); + tester.builder(from: '.5', to: '.6') + ..reads('.5') + ..resolvesOther('a.4') + ..writes('.6'); + }); + + test('a.6 is built', () async { + expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6'])); + }); + + test('change a.1, a.6 is rebuilt', () async { + expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6'])); + expect( + await tester.build(change: 'a.1'), + Result(written: ['a.2', 'a.6']), + ); + }); + }); + + // As previous group, but with builders in reverse order so the transitive + // import is not available and is not an input. + group('a.5 <== a.6, a.3 <== a.4, a.1 <== a.2, a.6 resolves: z -> a.4', () { + setUp(() { + tester.sources(['a.1', 'a.3', 'a.5', 'z']); + tester.importGraph({ + 'z': ['a.4'], + 'a.4': ['a.2'], + }); + tester.builder(from: '.5', to: '.6') + ..reads('.5') + ..resolvesOther('z') + ..writes('.6'); + tester.builder(from: '.3', to: '.4') + ..reads('.3') + ..writes('.4'); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..writes('.2'); + }); + + test('a.6 is built', () async { + expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6'])); + }); + + test('change a.1, a.6 is not rebuilt', () async { + expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6'])); + expect(await tester.build(change: 'a.1'), Result(written: ['a.2'])); + }); + }); +}