Skip to main content

qk_cache/
inputs.rs

1//! Declared task inputs shared by cache keys, revision-change selection and
2//! input analysis. Content evaluation remains in `hash`; revision loading and
3//! task dependency propagation remain with the caller.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::path::{Component, Path, PathBuf};
7use std::sync::atomic::AtomicBool;
8
9use anyhow::{Context, Result};
10use qk_config::Workspace;
11use qk_lockfile::Lockfile;
12use qk_taskgraph::TaskGraph;
13use serde_json::Value;
14
15use crate::{hash, paths};
16
17/// A task's selected file and non-file inputs, before cache-key evaluation.
18pub struct Resolved {
19    /// Workspace-relative files whose content is part of the key.
20    pub files: BTreeSet<String>,
21    /// Always-on workspace and package configuration, also useful to input analysis.
22    pub mandatory: BTreeSet<String>,
23    /// Env, runtime and other named values; env and runtime are evaluated only
24    /// with a prepared task.
25    pub values: BTreeMap<String, Value>,
26    /// With a readable pnpm lockfile: the importers whose installs count, and
27    /// packages named by `externalDependencies`.
28    pub lockfile: Option<(BTreeSet<String>, BTreeSet<String>)>,
29    /// `pnpm-workspace.yaml` counts without its resolution keys, which reach
30    /// tasks through the lockfile instead.
31    pub workspace_file: bool,
32    /// Explicit external dependency names, even without a readable lockfile.
33    pub external: BTreeSet<String>,
34}
35
36impl Resolved {
37    /// JSON projections still name whole files for conservative change selection.
38    pub fn file_inputs(&self) -> impl Iterator<Item = &str> {
39        self.files
40            .iter()
41            .map(String::as_str)
42            .chain(self.json_inputs())
43    }
44
45    pub fn json_inputs(&self) -> impl Iterator<Item = &str> {
46        self.value_names("json:")
47    }
48
49    pub fn is_json_input(&self, path: &str) -> bool {
50        self.values.contains_key(&format!("json:{path}"))
51    }
52
53    pub fn environment_inputs(&self) -> impl Iterator<Item = &str> {
54        self.value_names("env:")
55    }
56
57    pub fn runtime_inputs(&self) -> impl Iterator<Item = &str> {
58        self.value_names("runtime:")
59    }
60
61    /// Each dependency-output pattern and whether it includes transitive tasks.
62    pub fn dependency_output_inputs(&self) -> impl Iterator<Item = (&str, bool)> {
63        self.values.iter().filter_map(|(name, transitive)| {
64            name.strip_prefix("dependentTasksOutputFiles:")
65                .map(|pattern| (pattern, transitive == &Value::Bool(true)))
66        })
67    }
68
69    /// Installation-aware metadata is distinct from ordinary keyed file content.
70    pub fn is_resolution_metadata(&self, path: &str) -> bool {
71        (path == "pnpm-lock.yaml" && self.lockfile.is_some())
72            || (path == "pnpm-workspace.yaml" && self.workspace_file)
73    }
74
75    fn value_names<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a str> {
76        self.values
77            .keys()
78            .filter_map(move |name| name.strip_prefix(prefix))
79    }
80}
81
82/// Each task's resolved inputs, for affected selection. `extra` paths are
83/// candidates beside the workspace's files, so a deleted file still matches
84/// the inputs that named it. Env and runtime inputs are not evaluated.
85pub fn resolve_tasks(
86    workspace: &Workspace,
87    graph: &TaskGraph,
88    extra: &[String],
89) -> Result<BTreeMap<String, Resolved>> {
90    let cache = paths::cache_location(workspace);
91    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
92    let cancelled = AtomicBool::new(false);
93    graph
94        .tasks
95        .iter()
96        .map(|(id, task)| {
97            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
98                .with_context(|| format!("cannot resolve the inputs of {id}"))?;
99            Ok((id.clone(), resolved))
100        })
101        .collect()
102}
103
104/// Every task's resolved inputs, or why they could not be resolved, with the
105/// workspace files they were chosen from.
106pub struct Resolution {
107    pub candidates: std::collections::BTreeSet<String>,
108    pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
109}
110
111pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
112    let cache = paths::cache_location(workspace);
113    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
114    let cancelled = AtomicBool::new(false);
115    let tasks = graph
116        .tasks
117        .iter()
118        .map(|(id, task)| {
119            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
120                .map_err(|error| format!("{error:#}"));
121            (id.clone(), resolved)
122        })
123        .collect();
124    Ok(Resolution {
125        candidates: snapshot.files.clone(),
126        tasks,
127    })
128}
129
130/// A changed input's meaning, independent of revision loading and task reports.
131#[derive(Debug, PartialEq, Eq)]
132pub enum InputChange {
133    Input { file: String },
134    Installs { importer: String },
135    Package { name: String },
136    Lockfile,
137    WorkspaceFile,
138}
139
140/// Matches one revision change set against any number of resolved tasks, sharing
141/// linked-path traversal across tasks. Env and runtime values are not evaluated;
142/// the caller supplies lockfile revisions and the workspace-file comparison.
143pub struct InputChanges<'a> {
144    root: PathBuf,
145    files: &'a [String],
146    changed: BTreeSet<&'a str>,
147    lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
148    workspace_file_changed: bool,
149    links: BTreeMap<String, Vec<String>>,
150    directory_links: BTreeMap<PathBuf, Vec<PathBuf>>,
151}
152
153impl<'a> InputChanges<'a> {
154    /// `files` are workspace-relative changes. `lockfiles` are the readable base
155    /// and head revisions when the lockfile changed, or `None` otherwise;
156    /// `workspace_file_changed` excludes installation-resolution settings.
157    pub fn new(
158        root: &Path,
159        files: &'a [String],
160        lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
161        workspace_file_changed: bool,
162    ) -> Result<Self> {
163        Ok(Self {
164            root: root.canonicalize()?,
165            files,
166            changed: files.iter().map(String::as_str).collect(),
167            lockfiles,
168            workspace_file_changed,
169            links: BTreeMap::new(),
170            directory_links: BTreeMap::new(),
171        })
172    }
173
174    /// Sorted file reasons, then workspace metadata and installation reasons,
175    /// preserving the order used in affected-task reports.
176    pub fn reasons(&mut self, inputs: &Resolved) -> Result<Vec<InputChange>> {
177        let mut reasons = Vec::new();
178        let mut touched = BTreeSet::new();
179        for file in inputs.file_inputs() {
180            if self.changed.contains(file) {
181                touched.insert(file);
182                continue;
183            }
184            if !self.links.contains_key(file) {
185                self.links.insert(
186                    file.to_owned(),
187                    symlink_targets(&self.root, file, &mut self.directory_links)?,
188                );
189            }
190            for target in &self.links[file] {
191                touched.extend(
192                    self.files
193                        .iter()
194                        .filter(|changed| {
195                            target.is_empty()
196                                || **changed == *target
197                                || changed
198                                    .strip_prefix(target)
199                                    .is_some_and(|suffix| suffix.starts_with('/'))
200                        })
201                        .map(String::as_str),
202                );
203            }
204        }
205        for file in touched {
206            reasons.push(InputChange::Input {
207                file: file.to_owned(),
208            });
209        }
210        if inputs.workspace_file && self.workspace_file_changed {
211            reasons.push(InputChange::WorkspaceFile);
212        }
213        match (&inputs.lockfile, &self.lockfiles) {
214            (Some((importers, external)), Some((before, after))) => {
215                if before.global() != after.global() {
216                    reasons.push(InputChange::Lockfile);
217                }
218                for importer in importers {
219                    if before.installed(importer) != after.installed(importer) {
220                        reasons.push(InputChange::Installs {
221                            importer: importer.clone(),
222                        });
223                    }
224                }
225                for name in external {
226                    if before.package(name) != after.package(name) {
227                        reasons.push(InputChange::Package { name: name.clone() });
228                    }
229                }
230            }
231            (Some(_), None) if self.changed.contains("pnpm-lock.yaml") => {
232                reasons.push(InputChange::Input {
233                    file: "pnpm-lock.yaml".into(),
234                });
235            }
236            _ => {}
237        }
238        Ok(reasons)
239    }
240}
241
242/// `pnpm-workspace.yaml` without the keys that configure resolution, or `None`
243/// when it cannot be read as YAML.
244pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
245    let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
246    without_resolution(&text)
247}
248
249/// A `pnpm-workspace.yaml` text without its resolution keys.
250pub fn without_resolution(text: &str) -> Option<Value> {
251    let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
252    if let Some(object) = value.as_object_mut() {
253        object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
254    }
255    Some(value)
256}
257
258/// Paths a selected symlink reads, including a target that has been deleted.
259fn symlink_targets(
260    root: &Path,
261    path: &str,
262    directory_links: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
263) -> Result<Vec<String>> {
264    let mut pending = vec![root.join(path)];
265    let mut visited = BTreeSet::new();
266    let mut targets = BTreeSet::new();
267    while let Some(absolute) = pending.pop() {
268        if !visited.insert(absolute.clone()) {
269            continue;
270        }
271        let metadata = match std::fs::symlink_metadata(&absolute) {
272            Ok(metadata) => metadata,
273            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
274            Err(error) => return Err(error.into()),
275        };
276        if !metadata.file_type().is_symlink() {
277            continue;
278        }
279        let target = absolute
280            .parent()
281            .expect("input has a parent")
282            .join(std::fs::read_link(&absolute)?);
283        let mut normalized = PathBuf::new();
284        for component in target.components() {
285            match component {
286                Component::CurDir => {}
287                Component::ParentDir => {
288                    normalized.pop();
289                }
290                component => normalized.push(component.as_os_str()),
291            }
292        }
293        let resolved = target.canonicalize().ok().or_else(|| {
294            Some(
295                target
296                    .parent()?
297                    .canonicalize()
298                    .ok()?
299                    .join(target.file_name()?),
300            )
301        });
302        if normalized.starts_with(root) {
303            pending.push(normalized.clone());
304        }
305        if let Some(resolved) = &resolved
306            && resolved.starts_with(root)
307            && resolved.is_dir()
308        {
309            if !directory_links.contains_key(resolved) {
310                let mut links = Vec::new();
311                for entry in walkdir::WalkDir::new(resolved).follow_links(false) {
312                    let entry = entry?;
313                    if entry.file_type().is_symlink() {
314                        links.push(entry.into_path());
315                    }
316                }
317                directory_links.insert(resolved.clone(), links);
318            }
319            pending.extend(directory_links[resolved].iter().cloned());
320        }
321        for target in std::iter::once(normalized).chain(resolved) {
322            if let Ok(relative) = target.strip_prefix(root)
323                && let Some(relative) = relative.to_str()
324            {
325                targets.insert(relative.replace('\\', "/"));
326            }
327        }
328    }
329    Ok(targets.into_iter().collect())
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use qk_taskgraph::Request;
336    use serde_json::json;
337
338    fn workspace(
339        inputs: Value,
340        lockfile: Option<&str>,
341    ) -> (tempfile::TempDir, Workspace, TaskGraph) {
342        let root = tempfile::tempdir().unwrap();
343        std::fs::create_dir(root.path().join("app")).unwrap();
344        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
345        std::fs::write(
346            root.path().join("app/project.json"),
347            json!({
348                "name": "app", "targets": {"build": {"command": "echo build", "inputs": inputs}}
349            })
350            .to_string(),
351        )
352        .unwrap();
353        std::fs::write(root.path().join("app/meta.json"), r#"{"version":1}"#).unwrap();
354        if let Some(lockfile) = lockfile {
355            std::fs::write(root.path().join("pnpm-lock.yaml"), lockfile).unwrap();
356            std::fs::write(
357                root.path().join("pnpm-workspace.yaml"),
358                "packages:\n  - app\n",
359            )
360            .unwrap();
361        }
362        let workspace = Workspace::load(root.path()).unwrap();
363        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
364        (root, workspace, graph)
365    }
366
367    #[test]
368    fn shared_declarations_do_not_execute_runtime_inputs_or_read_environment_values() {
369        let (root, workspace, graph) = workspace(
370            json!([
371                {"json": "{projectRoot}/meta.json", "fields": ["version"]},
372                {"env": "PATH"}, {"runtime": "echo evaluated > runtime-marker"},
373                {"dependentTasksOutputFiles": "**/*.js", "transitive": true}
374            ]),
375            None,
376        );
377        let changed = vec!["runtime-marker".into(), "app/meta.json".into()];
378        let resolved = resolve_tasks(&workspace, &graph, &changed).unwrap();
379        let inputs = &resolved["app:build"];
380        assert_eq!(inputs.json_inputs().collect::<Vec<_>>(), ["app/meta.json"]);
381        assert_eq!(inputs.environment_inputs().collect::<Vec<_>>(), ["PATH"]);
382        assert_eq!(
383            inputs.runtime_inputs().collect::<Vec<_>>(),
384            ["echo evaluated > runtime-marker"]
385        );
386        assert_eq!(inputs.values["env:PATH"], Value::Null);
387        assert_eq!(
388            inputs.values["runtime:echo evaluated > runtime-marker"],
389            Value::Null
390        );
391        assert_eq!(
392            inputs.dependency_output_inputs().collect::<Vec<_>>(),
393            [("**/*.js", true)]
394        );
395        assert!(inputs.file_inputs().any(|path| path == "app/meta.json"));
396        assert_eq!(
397            InputChanges::new(root.path(), &changed, None, false)
398                .unwrap()
399                .reasons(inputs)
400                .unwrap(),
401            [InputChange::Input {
402                file: "app/meta.json".into()
403            }]
404        );
405        assert!(!root.path().join("runtime-marker").exists());
406        let each = resolve_each(&workspace, &graph).unwrap();
407        assert_eq!(
408            each.tasks["app:build"].as_ref().unwrap().values,
409            inputs.values
410        );
411        assert!(!root.path().join("runtime-marker").exists());
412    }
413
414    fn lockfile(app: &str, other: &str) -> String {
415        let versions = BTreeSet::from([app, other]);
416        let packages: String = versions
417            .iter()
418            .map(|version| {
419                format!("  react@{version}:\n    resolution: {{integrity: sha512-{version}}}\n")
420            })
421            .collect();
422        let snapshots: String = versions
423            .iter()
424            .map(|version| format!("  react@{version}: {{}}\n"))
425            .collect();
426        format!(
427            "lockfileVersion: '9.0'\nimporters:\n  .: {{}}\n  app:\n    dependencies:\n      react:\n        specifier: '*'\n        version: {app}\n  other:\n    dependencies:\n      react:\n        specifier: '*'\n        version: {other}\npackages:\n{packages}snapshots:\n{snapshots}"
428        )
429    }
430
431    #[test]
432    fn installation_changes_preserve_selectivity_fallback_and_reason_order() {
433        let before_text = lockfile("1.0.0", "1.0.0");
434        let (root, workspace, graph) = workspace(
435            json!([
436                {"json": "{projectRoot}/meta.json"}, {"externalDependencies": ["react"]}
437            ]),
438            Some(&before_text),
439        );
440        let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
441        let inputs = &resolved["app:build"];
442        assert!(inputs.is_resolution_metadata("pnpm-lock.yaml"));
443        assert!(inputs.is_resolution_metadata("pnpm-workspace.yaml"));
444        let before = Lockfile::parse(&before_text).unwrap();
445        let unrelated = Lockfile::parse(&lockfile("1.0.0", "2.0.0")).unwrap();
446        let changed = vec![
447            "pnpm-workspace.yaml".into(),
448            "pnpm-lock.yaml".into(),
449            "app/meta.json".into(),
450        ];
451        let mut changes =
452            InputChanges::new(root.path(), &changed, Some((&before, &unrelated)), true).unwrap();
453        // A named external package sees every importer; the task's own importer
454        // is unchanged, so this must not report an installation change for app.
455        assert_eq!(
456            changes.reasons(inputs).unwrap(),
457            [
458                InputChange::Input {
459                    file: "app/meta.json".into()
460                },
461                InputChange::WorkspaceFile,
462                InputChange::Package {
463                    name: "react".into()
464                },
465            ]
466        );
467        let after = Lockfile::parse(
468            &(lockfile("2.0.0", "2.0.0") + "settings:\n  autoInstallPeers: false\n"),
469        )
470        .unwrap();
471        assert_eq!(
472            InputChanges::new(root.path(), &changed, Some((&before, &after)), true)
473                .unwrap()
474                .reasons(inputs)
475                .unwrap(),
476            [
477                InputChange::Input {
478                    file: "app/meta.json".into()
479                },
480                InputChange::WorkspaceFile,
481                InputChange::Lockfile,
482                InputChange::Installs {
483                    importer: "app".into()
484                },
485                InputChange::Package {
486                    name: "react".into()
487                },
488            ]
489        );
490        assert_eq!(
491            InputChanges::new(root.path(), &changed, None, false)
492                .unwrap()
493                .reasons(inputs)
494                .unwrap(),
495            [
496                InputChange::Input {
497                    file: "app/meta.json".into()
498                },
499                InputChange::Input {
500                    file: "pnpm-lock.yaml".into()
501                },
502            ]
503        );
504    }
505}