Repository: vercel/turborepo (2026-03-31T17:32:06Z)

Issue:
affectedUsingTaskInputs: ^build dependencies not included for affected tasks
### Link to code that reproduces this issue

[https://github.com/BeyondEvil/turbo-affected-repro](<https://github.com/BeyondEvil/turbo-affected-repro>)

### Verify canary release

- [X] I verified that the issue exists in the latest Turborepo canary release.

### Which canary version will you have in your reproduction?

2.9.2-canary.2

### Environment information

```
CLI:
   Package manager: pnpm10

Platform:
   Architecture: aarch64
   Operating system: macos
```

### Expected behavior

When `affectedUsingTaskInputs` is enabled and a source file in `@repro/web` changes, `turbo run build --affected` should include `@repro/common#build` (the `^build` dependency) in the task graph. This matches the behavior without `affectedUsingTaskInputs`.

### Actual behavior

Only `@repro/web#build` is included. Its `^build` dependency `@repro/common#build` is excluded from the task graph entirely. Without a warm cache, this causes build failures because the dependency's `dist/` output doesn't exist.

### To Reproduce

Minimal monorepo setup:

```
turbo-repro/
  .github/workflows/ci.yml    # we want to exclude this from affected
  apps/web/                    # depends on @repro/common
  packages/common/             # shared library
  turbo.json
```

**turbo.json:**

```json
{
  "$schema": "https://turbo.build/schema.v2.json",
  "futureFlags": {
    "affectedUsingTaskInputs": true
  },
  "globalDependencies": [],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", "!.github/**"],
      "outputs": ["dist/**"]
    }
  }
}
```

**apps/web/package.json:**

```json
{
  "name": "@repro/web",
  "dependencies": { "@repro/common": "workspace:*" },
  "scripts": {
    "build": "echo 'building web' && mkdir -p dist && cp node_modules/@repro/common/dist/index.js dist/common.js"
  }
}
```

**packages/common/package.json:**

```json
{
  "name": "@repro/common",
  "scripts": {
    "build": "echo 'building common' && mkdir -p dist && echo 'export const hello = 1;' > dist/index.js"
  }
}
```

**Steps:**

```bash
# Warm cache
pnpm turbo run build

# Change only the app
echo "// changed" >> apps/web/src/index.ts

# Run affected build
TURBO_SCM_BASE=HEAD pnpm turbo run build --affected --dry-run
```

**With** `affectedUsingTaskInputs: true`:

```
Packages in scope: //, @repro/web
Running build in 2 packages
@repro/web#build                    <-- @repro/common#build is MISSING
```

**Without** `affectedUsingTaskInputs` (or with it set to `false`):

```
Packages in scope: //, @repro/common, @repro/web
Running build in 3 packages
@repro/common#build
  Dependents = @repro/web#build
@repro/web#build
  Dependencies = @repro/common#build    <-- correct
```

### Additional context

We're using `affectedUsingTaskInputs` to exclude `.github/` workflow files from triggering full monorepo builds. The exclusion works correctly -- `.github/`-only changes produce no affected tasks. But real source changes lose their `^build` dependency chain, making the feature unusable for any monorepo that uses topological dependencies.

The task-level pruning in `match_tasks_against_changed_files()` appears to remove dependency tasks that haven't changed, without considering that they're transitively required by affected tasks via `^build`. The pruned tasks should be kept (they'll get cache hits anyway).

Related: [TURBO-5360](https://linear.app/vercel/issue/TURBO-5360/futureflagsaffectedusingtaskinputs-not-picking-up-change-to-a), vercel/turborepo#12331


Discussion:
What a blunder. Not sure why I didn't think about this.

Working on fixing now.
That'll be fixed on the next canary; they publish hourly.

Thank you for trying this out, and do let me know if the canary that includes this fixes your problem. If it doesn't, I'll circle back.
Will do. 🙇 
The test-cases in the repro repo now all pass.

Thank you for the quick turnaround!

What's the ETA for when this will be included in a stable release? @anthonyshew 
Running a patch workflow for you here: https://github.com/vercel/turborepo/actions/runs/23816675056 👍 



Patch:
diff --git a/crates/turborepo-engine/src/lib.rs b/crates/turborepo-engine/src/lib.rs
index a8789089acf39..cd7c6d93c88c6 100644
--- a/crates/turborepo-engine/src/lib.rs
+++ b/crates/turborepo-engine/src/lib.rs
@@ -236,10 +236,17 @@ impl<T: TaskDefinitionInfo + Default + Clone> Default for Engine<Building, T> {
 }
 
 impl<T: TaskDefinitionInfo + Clone> Engine<Built, T> {
-    /// Creates an instance of `Engine` that only contains tasks that depend on
-    /// tasks from a given package. This is useful for watch mode, where we
-    /// need to re-run only a portion of the task graph.
-    pub fn create_engine_for_subgraph(&self, changed_packages: &HashSet<PackageName>) -> Self {
+    /// Creates an engine containing only tasks reachable from the given
+    /// packages: their direct tasks, transitive dependents, and all
+    /// transitive dependencies needed for execution. Persistent
+    /// non-interruptible tasks are excluded (they can't be restarted in
+    /// watch mode). Used by watch mode to scope rebuilds to the changed
+    /// portion of the task graph.
+    ///
+    /// Transitive dependencies are included because the executor needs them
+    /// to produce outputs that downstream tasks consume — on a cold cache
+    /// or first run, those outputs won't already be on disk.
+    pub fn create_engine_for_subgraph(self, changed_packages: &HashSet<PackageName>) -> Self {
         let entrypoint_indices: Vec<_> = changed_packages
             .iter()
             .filter_map(|pkg| self.package_tasks.get(pkg))
@@ -247,82 +254,53 @@ impl<T: TaskDefinitionInfo + Clone> Engine<Built, T> {
             .copied()
             .collect();
 
-        // Compute all nodes reachable from the entrypoint tasks by traversing
-        // dependents (incoming direction on the original graph = outgoing on
-        // reversed). Uses a multi-source DFS which is O(V+E), replacing the
-        // previous O(V^3) Floyd-Warshall approach.
-        let mut reachable = HashSet::new();
-        depth_first_search(Reversed(&self.task_graph), entrypoint_indices, |event| {
-            if let DfsEvent::Discover(n, _) = event {
-                reachable.insert(n);
-            }
-        });
-
-        let new_graph = self.task_graph.filter_map(
-            |node_idx, node| {
-                if !reachable.contains(&node_idx) {
-                    return None;
-                }
-
-                if let TaskNode::Task(task) = &self.task_graph[node_idx] {
-                    // We only want to include tasks that are not persistent
-                    let def = self
-                        .task_definitions
-                        .get(task)
-                        .expect("task should have definition");
-
-                    if def.persistent() && !def.interruptible() {
-                        return None;
-                    }
-                }
-                Some(node.clone())
-            },
-            |_, _| Some(()),
-        );
-
-        let task_lookup: HashMap<_, _> = new_graph
-            .node_indices()
-            .filter_map(|index| {
-                let task = new_graph
-                    .node_weight(index)
-                    .expect("node index should be present");
-                match task {
-                    TaskNode::Root => None,
-                    TaskNode::Task(task) => Some((task.clone(), index)),
-                }
-            })
-            .collect();
-
-        Engine {
-            marker: std::marker::PhantomData,
-            root_index: self.root_index,
-            task_graph: new_graph,
-            task_lookup,
-            task_definitions: self.task_definitions.clone(),
-            task_locations: self.task_locations.clone(),
-            package_tasks: self.package_tasks.clone(),
-            // We've filtered out persistent tasks
-            has_non_interruptible_tasks: false,
-        }
+        let reachable = self.reachable_closure(entrypoint_indices);
+        self.prune_to_reachable(&reachable, true)
     }
 
-    /// Returns a new engine containing only the given directly affected tasks
-    /// and their transitive dependents. Used for task-level `--affected`
-    /// detection: the caller determines which tasks' `inputs` match the changed
-    /// files, then this method expands that set to include downstream tasks and
-    /// prunes everything else.
+    /// Returns a new engine containing only the given directly affected tasks,
+    /// their transitive dependents, and all transitive dependencies required
+    /// for execution. Used for task-level `--affected` detection: the caller
+    /// determines which tasks' `inputs` match the changed files, then this
+    /// method expands that set to include downstream tasks and their full
+    /// dependency chains, pruning everything else.
+    ///
+    /// Dependencies of affected tasks are included because the executor
+    /// needs them in the graph to restore cached outputs before running
+    /// the affected tasks that consume them.
     ///
     /// This consumes and returns a new engine rather than mutating in place,
-    /// consistent with `create_engine_for_subgraph` and the sealed typestate
-    /// contract. Must be called during build, before the engine is shared
-    /// via `Arc` or handed to the executor.
-    pub fn retain_affected_tasks(mut self, affected_tasks: &HashSet<TaskId>) -> Self {
+    /// following the sealed typestate contract. Must be called during build,
+    /// before the engine is shared via `Arc` or handed to the executor.
+    pub fn retain_affected_tasks(self, affected_tasks: &HashSet<TaskId>) -> Self {
         let entrypoint_indices: Vec<_> = affected_tasks
             .iter()
             .filter_map(|task_id| self.task_lookup.get(task_id))
             .copied()
             .collect();
 
+        let original_task_count = self.task_graph.node_count().saturating_sub(1);
+        let reachable = self.reachable_closure(entrypoint_indices);
+        let retained_task_count = reachable.len().saturating_sub(1);
+
+        tracing::info!(
+            directly_affected = affected_tasks.len(),
+            retained = retained_task_count,
+            pruned = original_task_count.saturating_sub(retained_task_count),
+            "task graph pruned for --affected"
+        );
+
+        self.prune_to_reachable(&reachable, false)
+    }
+
+    /// Computes the full reachable set from seed nodes: reverse DFS for
+    /// transitive dependents, then forward DFS for transitive dependencies.
+    /// Root is always included so `prune_to_reachable` can recover it.
+    fn reachable_closure(
+        &self,
+        entrypoint_indices: Vec<petgraph::graph::NodeIndex>,
+    ) -> HashSet<petgraph::graph::NodeIndex> {
+        // Reverse DFS: find transitive dependents (downstream consumers).
         let mut reachable = HashSet::new();
         reachable.insert(self.root_index);
         depth_first_search(Reversed(&self.task_graph), entrypoint_indices, |event| {
@@ -331,13 +309,50 @@ impl<T: TaskDefinitionInfo + Clone> Engine<Built, T> {
             }
         });
 
+        // Forward DFS: find transitive dependencies (upstream tasks needed as
+        // cache hits). Root is excluded as a seed since it has no outgoing
+        // edges in the forward direction.
+        let forward_seeds: Vec<_> = reachable
+            .iter()
+            .copied()
+            .filter(|&n| n != self.root_index)
+            .collect();
+        depth_first_search(&self.task_graph, forward_seeds, |event| {
+            if let DfsEvent::Discover(n, _) = event {
+                reachable.insert(n);
+            }
+        });
+
+        reachable
+    }
+
+    /// Prunes the engine graph to only nodes in `reachable` and rebuilds all
+    /// metadata (`task_lookup`, `root_index`, `task_definitions`,
+    /// `task_locations`, `package_tasks`, `has_non_interruptible_tasks`).
+    ///
+    /// When `exclude_non_interruptible_persistent` is true, persistent
+    /// non-interruptible tasks are also filtered out even if reachable (used
+    /// by watch mode).
+    fn prune_to_reachable(
+        mut self,
+        reachable: &HashSet<petgraph::graph::NodeIndex>,
+        exclude_non_interruptible_persistent: bool,
+    ) -> Self {
         self.task_graph = self.task_graph.filter_map(
             |node_idx, node| {
-                if reachable.contains(&node_idx) {
-                    Some(node.clone())
-                } else {
-                    None
+                if !reachable.contains(&node_idx) {
+                    return None;
+                }
+                if exclude_non_interruptible_persistent && let TaskNode::Task(task) = node {
+                    let def = self
+                        .task_definitions
+                        .get(task)
+                        .expect("task should have definition");
+                    if def.persistent() && !def.interruptible() {
+                        return None;
+                    }
                 }
+                Some(node.clone())
             },
             |_, _| Some(()),
         );
@@ -771,37 +786,71 @@ mod affected_tasks_tests {
     }
 
     #[test]
-    fn affected_terminal_includes_only_itself() {
+    fn affected_terminal_includes_dependency_chain() {
         let engine = build_linear_engine();
         let affected: HashSet<_> = [TaskId::new("c", "build")].into_iter().collect();
         let engine = engine.retain_affected_tasks(&affected);
 
+        // c is affected, but c depends on b which depends on a.
+        // All three must survive so the executor can run the full
+        // dependency chain (a and b will be cache hits).
         let ids = task_ids_set(&engine);
-        assert_eq!(ids.len(), 1);
+        assert_eq!(
+            ids.len(),
+            3,
+            "full dependency chain should survive: {ids:?}"
+        );
+        assert!(ids.contains(&TaskId::new("a", "build")));
+        assert!(ids.contains(&TaskId::new("b", "build")));
         assert!(ids.contains(&TaskId::new("c", "build")));
     }
 
     #[test]
-    fn affected_middle_includes_downstream() {
+    fn affected_middle_includes_deps_and_dependents() {
         let engine = build_linear_engine();
         let affected: HashSet<_> = [TaskId::new("b", "build")].into_iter().collect();
         let engine = engine.retain_affected_tasks(&affected);
 
+        // b is affected. c depends on b (dependent, needs re-run).
+        // b depends on a (dependency, needed for execution as a cache hit).
         let ids = task_ids_set(&engine);
-        assert_eq!(ids.len(), 2);
+        assert_eq!(ids.len(), 3, "deps + dependents should survive: {ids:?}");
+        assert!(ids.contains(&TaskId::new("a", "build")));
         assert!(ids.contains(&TaskId::new("b", "build")));
         assert!(ids.contains(&TaskId::new("c", "build")));
     }
 
     #[test]
     fn metadata_pruned_to_surviving_tasks() {
-        let engine = build_linear_engine();
-        let affected: HashSet<_> = [TaskId::new("c", "build")].into_iter().collect();
+        let mut engine: Engine<Building> = Engine::new();
+
+        let a = TaskId::new("a", "build");
+        let b = TaskId::new("b", "build");
+        let x = TaskId::new("x", "build");
+
+        let a_idx = engine.get_index(&a);
+        let b_idx = engine.get_index(&b);
+        engine.get_index(&x);
+
+        engine.add_definition(a.clone(), TaskInfo::default());
+        engine.add_definition(b.clone(), TaskInfo::default());
+        engine.add_definition(x.clone(), TaskInfo::default());
+
+        engine.task_graph_mut().add_edge(b_idx, a_idx, ());
+        engine.connect_to_root(&a);
+        engine.connect_to_root(&x);
+
+        let engine = engine.seal();
+        let affected: HashSet<_> = [TaskId::new("a", "build")].into_iter().collect();
         let engine = engine.retain_affected_tasks(&affected);
 
-        assert!(engine.task_definition(&TaskId::new("c", "build")).is_some());
-        assert!(engine.task_definition(&TaskId::new("a", "build")).is_none());
-        assert!(engine.task_definition(&TaskId::new("b", "build")).is_none());
+        // a and b survive (a affected, b depends on a). x is unrelated and pruned.
+        assert!(engine.task_definition(&TaskId::new("a", "build")).is_some());
+        assert!(engine.task_definition(&TaskId::new("b", "build")).is_some());
+        assert!(
+            engine.task_definition(&TaskId::new("x", "build")).is_none(),
+            "unrelated task metadata should be pruned"
+        );
     }
 
     #[test]
@@ -915,6 +964,10 @@ mod affected_tasks_tests {
 
         let ids = task_ids_set(&engine);
         assert_eq!(ids.len(), 4, "all diamond tasks should survive: {ids:?}");
+        assert!(ids.contains(&TaskId::new("a", "build")));
+        assert!(ids.contains(&TaskId::new("b", "build")));
+        assert!(ids.contains(&TaskId::new("c", "build")));
+        assert!(ids.contains(&TaskId::new("d", "build")));
     }
 
     #[test]
@@ -943,13 +996,17 @@ mod affected_tasks_tests {
         engine.connect_to_root(&a);
 
         let engine = engine.seal();
-        // Only b is affected — d depends on b, so d is included. a and c are not.
+        // b is affected. d depends on b (dependent). b depends on a (dependency).
+        // d also depends on c which depends on a. All four must survive so d's
+        // full dependency diamond can execute.
         let affected: HashSet<_> = [TaskId::new("b", "build")].into_iter().collect();
         let engine = engine.retain_affected_tasks(&affected);
 
         let ids = task_ids_set(&engine);
-        assert_eq!(ids.len(), 2, "b and d should survive: {ids:?}");
+        assert_eq!(ids.len(), 4, "full diamond should survive: {ids:?}");
+        assert!(ids.contains(&TaskId::new("a", "build")));
         assert!(ids.contains(&TaskId::new("b", "build")));
+        assert!(ids.contains(&TaskId::new("c", "build")));
         assert!(ids.contains(&TaskId::new("d", "build")));
     }
 
@@ -966,6 +1023,46 @@ mod affected_tasks_tests {
 
         assert_eq!(ids_first, ids_second, "retain should be idempotent");
     }
+
+    /// Regression test for https://github.com/vercel/turborepo/issues/12512
+    ///
+    /// When an app's source changes and it has a ^build dependency on a lib,
+    /// the lib's build task must remain in the graph even though the lib's
+    /// inputs didn't change. Without it, the executor can't restore the
+    /// lib's cached output before the app tries to consume it.
+    #[test]
+    fn affected_app_retains_lib_dependency() {
+        let mut engine: Engine<Building> = Engine::new();
+
+        let lib_build = TaskId::new("common", "build");
+        let app_build = TaskId::new("web", "build");
+
+        let lib_idx = engine.get_index(&lib_build);
+        let app_idx = engine.get_index(&app_build);
+
+        engine.add_definition(lib_build.clone(), TaskInfo::default());
+        engine.add_definition(app_build.clone(), TaskInfo::default());
+
+        // web#build depends on common#build (^build)
+        engine.task_graph_mut().add_edge(app_idx, lib_idx, ());
+        engine.connect_to_root(&lib_build);
+
+        let engine = engine.seal();
+
+        // Only web's source changed — common is not directly affected.
+        let affected: HashSet<_> = [TaskId::new("web", "build")].into_iter().collect();
+        let engine = engine.retain_affected_tasks(&affected);
+
+        let ids = task_ids_set(&engine);
+        assert!(
+            ids.contains(&TaskId::new("web", "build")),
+            "affected app task should survive"
+        );
+        assert!(
+            ids.contains(&TaskId::new("common", "build")),
+            "^build dependency must survive for the app to execute"
+        );
+    }
 }
 
 use std::sync::{Arc, Mutex};
diff --git a/crates/turborepo-lib/src/engine/mod.rs b/crates/turborepo-lib/src/engine/mod.rs
index d687a58ef46d8..b7abfc24e8134 100644
--- a/crates/turborepo-lib/src/engine/mod.rs
+++ b/crates/turborepo-lib/src/engine/mod.rs
@@ -228,7 +228,7 @@ fn validate_interactive(engine: &Engine<Built>, ui_mode: UIMode) -> Vec<Validate
 #[cfg(test)]
 mod test {
 
-    use std::collections::BTreeMap;
+    use std::collections::{BTreeMap, HashSet};
 
     use tempfile::TempDir;
     use turbopath::AbsoluteSystemPath;
@@ -469,15 +469,54 @@ mod test {
         let subgraph =
             engine.create_engine_for_subgraph(&[PackageName::from("a")].into_iter().collect());
 
-        // Verify that the subgraph only contains tasks from package `a` and the `build`
-        // task from package `b`
+        // a's tasks are entrypoints, b#build is a transitive dependent, and
+        // Root is preserved for graph integrity. b#dev has no dependency on
+        // any of a's tasks so it's pruned.
         let tasks: Vec<_> = subgraph.tasks().collect();
-        assert_eq!(tasks.len(), 3);
+        assert_eq!(tasks.len(), 4);
+        assert!(tasks.contains(&&TaskNode::Root));
         assert!(tasks.contains(&&TaskNode::Task(a_build_task_id)));
         assert!(tasks.contains(&&TaskNode::Task(a_dev_task_id)));
         assert!(tasks.contains(&&TaskNode::Task(b_build_task_id)));
     }
 
+    /// When a downstream package changes in watch mode, the subgraph must
+    /// include its upstream dependency tasks so the executor can produce
+    /// their outputs (cold cache, first run, or cleared cache).
+    #[test]
+    fn test_subgraph_retains_upstream_dependency() {
+        let mut engine: Engine<Building> = Engine::new();
+
+        let lib_build = TaskId::new("lib", "build");
+        let app_build = TaskId::new("app", "build");
+
+        let lib_idx = engine.get_index(&lib_build);
+        let app_idx = engine.get_index(&app_build);
+
+        engine.add_definition(lib_build.clone(), TaskDefinition::default());
+        engine.add_definition(app_build.clone(), TaskDefinition::default());
+
+        // app#build depends on lib#build (^build)
+        engine.task_graph_mut().add_edge(app_idx, lib_idx, ());
+        engine.connect_to_root(&lib_build);
+
+        let engine = engine.seal();
+
+        // Only app's files changed — lib is untouched.
+        let subgraph =
+            engine.create_engine_for_subgraph(&[PackageName::from("app")].into_iter().collect());
+
+        let task_ids: HashSet<_> = subgraph.task_ids().cloned().collect();
+        assert!(
+            task_ids.contains(&app_build),
+            "changed package's task should survive"
+        );
+        assert!(
+            task_ids.contains(&lib_build),
+            "upstream dependency must survive — executor needs it to produce outputs on cold cache"
+        );
+    }
+
     #[tokio::test]
     async fn test_tasks_impacted_by_packages() {
         // Tests the batched transitive dependents lookup for watch mode.
diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs
index dea1f05fc834f..5255093033653 100644
--- a/crates/turborepo-lib/src/run/builder.rs
+++ b/crates/turborepo-lib/src/run/builder.rs
@@ -965,12 +965,11 @@ impl RunBuilder {
         // In watch mode with the future flag, filter the engine to only tasks
         // whose declared inputs match the changed files.
         //
-        // When active, this REPLACES create_engine_for_subgraph because:
-        // 1. retain_affected_tasks already selects the correct tasks + dependents
-        // 2. The entrypoint packages (from file watcher events) may not overlap with
-        //    the affected tasks (e.g. a $TURBO_ROOT$ input in another package)
-        // 3. retain_affected_tasks requires the Root sentinel node, which
-        //    create_engine_for_subgraph removes
+        // When active, this REPLACES create_engine_for_subgraph because
+        // the entrypoint packages (from file watcher events) may not overlap
+        // with the affected tasks (e.g. a $TURBO_ROOT$ input in another
+        // package changes but the watcher only reports the package containing
+        // the file).
         let watch_task_filtered = if let Some(ref changed_files) = self.changed_files_for_watch {
             if self.opts.future_flags.watch_using_task_inputs && !changed_files.is_empty() {
                 // Only consider files that still exist on disk. Editor temp
diff --git a/crates/turborepo-lib/src/run/task_filter.rs b/crates/turborepo-lib/src/run/task_filter.rs
index 7caeee2e809a3..f0bffed7b524f 100644
--- a/crates/turborepo-lib/src/run/task_filter.rs
+++ b/crates/turborepo-lib/src/run/task_filter.rs
@@ -30,8 +30,9 @@ use crate::engine::Engine;
 ///
 /// Each include selector contributes a set of tasks (unioned together).
 /// Exclude selectors remove tasks from the result. After all selectors
-/// are processed, the engine is pruned to the surviving tasks plus their
-/// transitive dependents (so downstream tasks still run).
+/// are processed, the engine is pruned to the surviving tasks, their
+/// transitive dependents, and all transitive dependencies needed for
+/// execution.
 pub fn filter_engine_to_tasks(
     engine: Engine,
     selectors: &[TargetSelector],
@@ -77,9 +78,8 @@ pub fn filter_engine_to_tasks(
         return Ok(engine.retain_affected_tasks(&included_tasks));
     }
 
-    // retain_affected_tasks expands to transitive dependents and prunes the
-    // rest, which is exactly what we want — downstream tasks of matched tasks
-    // still need to run.
+    // retain_affected_tasks expands to transitive dependents, includes
+    // transitive dependencies for execution, and prunes the rest.
     Ok(engine.retain_affected_tasks(&included_tasks))
 }
 
diff --git a/crates/turborepo-lib/src/task_change_detector.rs b/crates/turborepo-lib/src/task_change_detector.rs
index 7342ab00e0909..290cb7ce0461f 100644
--- a/crates/turborepo-lib/src/task_change_detector.rs
+++ b/crates/turborepo-lib/src/task_change_detector.rs
@@ -24,8 +24,9 @@ use crate::engine::Engine;
 const DEFAULT_GLOBAL_DEPS: &[&str] = &["turbo.json", "turbo.jsonc"];
 
 /// Determines which tasks are directly affected by the given set of changed
-/// files. Does NOT include transitive dependents — use
-/// `Engine::retain_affected_tasks` afterward for that.
+/// files. Does NOT expand to transitive dependents or dependencies — use
+/// `Engine::retain_affected_tasks` afterward to include downstream
+/// dependents and upstream dependencies needed for execution.
 ///
 /// Checks all tasks against all changed files regardless of package boundaries.
 /// This is what makes cross-package inputs (`$TURBO_ROOT$/schema/api.json`)
diff --git a/crates/turborepo/ARCHITECTURE.md b/crates/turborepo/ARCHITECTURE.md
index f2d583da84554..516873a92b43e 100644
--- a/crates/turborepo/ARCHITECTURE.md
+++ b/crates/turborepo/ARCHITECTURE.md
@@ -55,7 +55,9 @@ construction:
 3. **Task change detection** (`turborepo-lib/src/task_change_detector.rs`):
    Determines directly affected tasks, handling global deps and per-task inputs
 4. **Engine pruning** (`Engine::retain_affected_tasks`): Returns a new engine
-   containing only directly affected tasks plus their transitive dependents
+   containing directly affected tasks, their transitive dependents, and all
+   transitive dependencies required for execution (upstream tasks needed as
+   cache hits)
 
 This differs from the default `--affected` behavior which operates at the
 package level (all tasks in changed packages run).


Test patch:
diff --git a/crates/turborepo/tests/affected_test.rs b/crates/turborepo/tests/affected_test.rs
index 6aff03042359f..dc1dc0acdee39 100644
--- a/crates/turborepo/tests/affected_test.rs
+++ b/crates/turborepo/tests/affected_test.rs
@@ -848,6 +848,55 @@ fn test_task_level_affected_source_file_change() {
     );
 }
 
+/// Regression test for https://github.com/vercel/turborepo/issues/12512
+///
+/// When only an app's source changes and it has a `^build` dependency on a
+/// lib, the lib's build task must appear in the dry-run output even though
+/// the lib's inputs didn't change. The executor needs it to restore cached
+/// outputs before running the app's build.
+#[test]
+fn test_task_level_affected_app_change_retains_lib_dependency() {
+    let tempdir = tempfile::tempdir().unwrap();
+    setup_task_level_affected(tempdir.path());
+
+    // Change only app-a's source — lib-a is untouched.
+    fs::write(
+        tempdir.path().join("packages/app-a/index.ts"),
+        "export const changed = true;",
+    )
+    .unwrap();
+
+    let output = run_turbo(
+        tempdir.path(),
+        &["run", "build", "--affected", "--dry=json"],
+    );
+    assert!(output.status.success());
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    let json: serde_json::Value = serde_json::from_str(&stdout)
+        .unwrap_or_else(|e| panic!("failed to parse dry run JSON: {e}\nstdout: {stdout}"));
+
+    let tasks = json["tasks"].as_array().expect("tasks array");
+    let task_ids: Vec<&str> = tasks
+        .iter()
+        .map(|t| t["taskId"].as_str().unwrap())
+        .collect();
+
+    assert!(
+        task_ids.contains(&"app-a#build"),
+        "app-a#build should be directly affected: {task_ids:?}"
+    );
+    assert!(
+        task_ids.contains(&"lib-a#build"),
+        "lib-a#build must survive as a ^build dependency for the executor: {task_ids:?}"
+    );
+    // lib-a's other tasks should NOT be included — only build is a
+    // dependency of app-a via ^build.
+    assert!(
+        !task_ids.contains(&"lib-a#test"),
+        "lib-a#test is not a dependency of app-a#build: {task_ids:?}"
+    );
+}
+
 #[test]
 fn test_task_level_affected_md_excludes_test_and_typecheck() {
     let tempdir = tempfile::tempdir().unwrap();