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    /// Dependency manifests keyed by what they decide for dependents, rather
50    /// than as files. See [`manifest_for_dependents`].
51    pub fn manifest_inputs(&self) -> impl Iterator<Item = &str> {
52        self.value_names("manifest:")
53    }
54
55    pub fn is_json_input(&self, path: &str) -> bool {
56        self.values.contains_key(&format!("json:{path}"))
57    }
58
59    pub fn environment_inputs(&self) -> impl Iterator<Item = &str> {
60        self.value_names("env:")
61    }
62
63    pub fn runtime_inputs(&self) -> impl Iterator<Item = &str> {
64        self.value_names("runtime:")
65    }
66
67    /// Each dependency-output pattern and whether it includes transitive tasks.
68    pub fn dependency_output_inputs(&self) -> impl Iterator<Item = (&str, bool)> {
69        self.values.iter().filter_map(|(name, transitive)| {
70            name.strip_prefix("dependentTasksOutputFiles:")
71                .map(|pattern| (pattern, transitive == &Value::Bool(true)))
72        })
73    }
74
75    /// Installation-aware metadata is distinct from ordinary keyed file content.
76    pub fn is_resolution_metadata(&self, path: &str) -> bool {
77        (path == "pnpm-lock.yaml" && self.lockfile.is_some())
78            || (path == "pnpm-workspace.yaml" && self.workspace_file)
79    }
80
81    fn value_names<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a str> {
82        self.values
83            .keys()
84            .filter_map(move |name| name.strip_prefix(prefix))
85    }
86}
87
88/// Each task's resolved inputs, for affected selection. `extra` paths are
89/// candidates beside the workspace's files, so a deleted file still matches
90/// the inputs that named it. Env and runtime inputs are not evaluated.
91pub fn resolve_tasks(
92    workspace: &Workspace,
93    graph: &TaskGraph,
94    extra: &[String],
95) -> Result<BTreeMap<String, Resolved>> {
96    let cache = paths::cache_location(workspace);
97    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
98    let cancelled = AtomicBool::new(false);
99    graph
100        .tasks
101        .iter()
102        .map(|(id, task)| {
103            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
104                .with_context(|| format!("cannot resolve the inputs of {id}"))?;
105            Ok((id.clone(), resolved))
106        })
107        .collect()
108}
109
110/// Every task's resolved inputs, or why they could not be resolved, with the
111/// workspace files they were chosen from.
112pub struct Resolution {
113    pub candidates: std::collections::BTreeSet<String>,
114    pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
115}
116
117pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
118    let cache = paths::cache_location(workspace);
119    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
120    let cancelled = AtomicBool::new(false);
121    let tasks = graph
122        .tasks
123        .iter()
124        .map(|(id, task)| {
125            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
126                .map_err(|error| format!("{error:#}"));
127            (id.clone(), resolved)
128        })
129        .collect();
130    Ok(Resolution {
131        candidates: snapshot.files.clone(),
132        tasks,
133    })
134}
135
136/// A changed input's meaning, independent of revision loading and task reports.
137#[derive(Debug, PartialEq, Eq)]
138pub enum InputChange {
139    Input { file: String },
140    Installs { importer: String },
141    Package { name: String },
142    Lockfile,
143    WorkspaceFile,
144}
145
146/// Matches one revision change set against any number of resolved tasks, sharing
147/// linked-path traversal across tasks. Env and runtime values are not evaluated;
148/// the caller supplies lockfile revisions and the workspace-file comparison.
149pub struct InputChanges<'a> {
150    root: PathBuf,
151    files: &'a [String],
152    changed: BTreeSet<&'a str>,
153    lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
154    workspace_file_changed: bool,
155    /// Changed manifests' base and head texts, `None` where absent.
156    manifests: BTreeMap<String, (Option<String>, Option<String>)>,
157    links: BTreeMap<String, Vec<String>>,
158    directory_links: BTreeMap<PathBuf, Vec<PathBuf>>,
159}
160
161impl<'a> InputChanges<'a> {
162    /// `files` are workspace-relative changes. `lockfiles` are the readable base
163    /// and head revisions when the lockfile changed, or `None` otherwise;
164    /// `workspace_file_changed` excludes installation-resolution settings.
165    pub fn new(
166        root: &Path,
167        files: &'a [String],
168        lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
169        workspace_file_changed: bool,
170    ) -> Result<Self> {
171        Ok(Self {
172            root: root.canonicalize()?,
173            files,
174            changed: files.iter().map(String::as_str).collect(),
175            lockfiles,
176            workspace_file_changed,
177            manifests: BTreeMap::new(),
178            links: BTreeMap::new(),
179            directory_links: BTreeMap::new(),
180        })
181    }
182
183    /// The base and head texts of changed manifests, so that a dependency's
184    /// manifest counts by what it decides for dependents. A changed manifest
185    /// without them counts as changed.
186    pub fn with_manifests(
187        mut self,
188        manifests: BTreeMap<String, (Option<String>, Option<String>)>,
189    ) -> Self {
190        self.manifests = manifests;
191        self
192    }
193
194    /// Sorted file reasons, then workspace metadata and installation reasons,
195    /// preserving the order used in affected-task reports.
196    pub fn reasons(&mut self, inputs: &Resolved) -> Result<Vec<InputChange>> {
197        let mut reasons = Vec::new();
198        let mut touched = BTreeSet::new();
199        for file in inputs.file_inputs() {
200            if self.changed.contains(file) {
201                touched.insert(file);
202                continue;
203            }
204            if !self.links.contains_key(file) {
205                self.links.insert(
206                    file.to_owned(),
207                    symlink_targets(&self.root, file, &mut self.directory_links)?,
208                );
209            }
210            for target in &self.links[file] {
211                touched.extend(
212                    self.files
213                        .iter()
214                        .filter(|changed| {
215                            target.is_empty()
216                                || **changed == *target
217                                || changed
218                                    .strip_prefix(target)
219                                    .is_some_and(|suffix| suffix.starts_with('/'))
220                        })
221                        .map(String::as_str),
222                );
223            }
224        }
225        let lockfile = inputs.lockfile.is_some();
226        for file in inputs.manifest_inputs() {
227            if !self.changed.contains(file) {
228                continue;
229            }
230            let unchanged = self.manifests.get(file).is_some_and(|(before, after)| {
231                let read = |text: &Option<String>| {
232                    text.as_deref()
233                        .and_then(|text| manifest_for_dependents(text, lockfile))
234                };
235                let before = read(before);
236                before.is_some() && before == read(after)
237            });
238            if !unchanged {
239                touched.insert(file);
240            }
241        }
242        for file in touched {
243            reasons.push(InputChange::Input {
244                file: file.to_owned(),
245            });
246        }
247        if inputs.workspace_file && self.workspace_file_changed {
248            reasons.push(InputChange::WorkspaceFile);
249        }
250        match (&inputs.lockfile, &self.lockfiles) {
251            (Some((importers, external)), Some((before, after))) => {
252                if before.global() != after.global() {
253                    reasons.push(InputChange::Lockfile);
254                }
255                for importer in importers {
256                    if before.installed(importer) != after.installed(importer) {
257                        reasons.push(InputChange::Installs {
258                            importer: importer.clone(),
259                        });
260                    }
261                }
262                for name in external {
263                    if before.package(name) != after.package(name) {
264                        reasons.push(InputChange::Package { name: name.clone() });
265                    }
266                }
267            }
268            (Some(_), None) if self.changed.contains("pnpm-lock.yaml") => {
269                reasons.push(InputChange::Input {
270                    file: "pnpm-lock.yaml".into(),
271                });
272            }
273            _ => {}
274        }
275        Ok(reasons)
276    }
277}
278
279/// Manifest fields that cannot change what a dependent task reads or runs: a
280/// dependency's scripts run only as its own tasks, and the rest is metadata
281/// that resolvers and bundlers do not read. A version can still add or remove
282/// a dependency through the range a dependent declares; that changes the
283/// project graph, and the dependent's key with it.
284const DEPENDENT_IGNORED_FIELDS: &[&str] = &[
285    "version",
286    "scripts",
287    "description",
288    "keywords",
289    "author",
290    "contributors",
291    "maintainers",
292    "license",
293    "homepage",
294    "repository",
295    "bugs",
296    "funding",
297    "private",
298    "publishConfig",
299];
300
301/// Dependency declarations, whose effect reaches dependents through the
302/// lockfile's record of what each importer installs.
303const DECLARATION_FIELDS: &[&str] = &[
304    "dependencies",
305    "devDependencies",
306    "peerDependencies",
307    "peerDependenciesMeta",
308    "optionalDependencies",
309    "dependenciesMeta",
310];
311
312/// A dependency's `package.json` as its dependents' keys read it: without
313/// fields that cannot change what they read or run, and, when the lockfile
314/// records the installs, without the declarations it resolved. `None` when it
315/// cannot be read as a JSON object.
316pub fn manifest_for_dependents(text: &str, lockfile: bool) -> Option<Value> {
317    let mut value: Value = serde_json::from_str(text).ok()?;
318    let object = value.as_object_mut()?;
319    object.retain(|key, _| {
320        !DEPENDENT_IGNORED_FIELDS.contains(&key.as_str())
321            && !(lockfile && DECLARATION_FIELDS.contains(&key.as_str()))
322    });
323    Some(value)
324}
325
326/// `pnpm-workspace.yaml` without the keys that configure resolution, or `None`
327/// when it cannot be read as YAML.
328pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
329    let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
330    without_resolution(&text)
331}
332
333/// A `pnpm-workspace.yaml` text without its resolution keys.
334pub fn without_resolution(text: &str) -> Option<Value> {
335    let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
336    if let Some(object) = value.as_object_mut() {
337        object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
338    }
339    Some(value)
340}
341
342/// Paths a selected symlink reads, including a target that has been deleted.
343fn symlink_targets(
344    root: &Path,
345    path: &str,
346    directory_links: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
347) -> Result<Vec<String>> {
348    let mut pending = vec![root.join(path)];
349    let mut visited = BTreeSet::new();
350    let mut targets = BTreeSet::new();
351    while let Some(absolute) = pending.pop() {
352        if !visited.insert(absolute.clone()) {
353            continue;
354        }
355        let metadata = match std::fs::symlink_metadata(&absolute) {
356            Ok(metadata) => metadata,
357            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
358            Err(error) => return Err(error.into()),
359        };
360        if !metadata.file_type().is_symlink() {
361            continue;
362        }
363        let target = absolute
364            .parent()
365            .expect("input has a parent")
366            .join(std::fs::read_link(&absolute)?);
367        let mut normalized = PathBuf::new();
368        for component in target.components() {
369            match component {
370                Component::CurDir => {}
371                Component::ParentDir => {
372                    normalized.pop();
373                }
374                component => normalized.push(component.as_os_str()),
375            }
376        }
377        let resolved = target.canonicalize().ok().or_else(|| {
378            Some(
379                target
380                    .parent()?
381                    .canonicalize()
382                    .ok()?
383                    .join(target.file_name()?),
384            )
385        });
386        if normalized.starts_with(root) {
387            pending.push(normalized.clone());
388        }
389        if let Some(resolved) = &resolved
390            && resolved.starts_with(root)
391            && resolved.is_dir()
392        {
393            if !directory_links.contains_key(resolved) {
394                let mut links = Vec::new();
395                for entry in walkdir::WalkDir::new(resolved).follow_links(false) {
396                    let entry = entry?;
397                    if entry.file_type().is_symlink() {
398                        links.push(entry.into_path());
399                    }
400                }
401                directory_links.insert(resolved.clone(), links);
402            }
403            pending.extend(directory_links[resolved].iter().cloned());
404        }
405        for target in std::iter::once(normalized).chain(resolved) {
406            if let Ok(relative) = target.strip_prefix(root)
407                && let Some(relative) = relative.to_str()
408            {
409                targets.insert(relative.replace('\\', "/"));
410            }
411        }
412    }
413    Ok(targets.into_iter().collect())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use qk_taskgraph::Request;
420    use serde_json::json;
421
422    fn workspace(
423        inputs: Value,
424        lockfile: Option<&str>,
425    ) -> (tempfile::TempDir, Workspace, TaskGraph) {
426        let root = tempfile::tempdir().unwrap();
427        std::fs::create_dir(root.path().join("app")).unwrap();
428        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
429        std::fs::write(
430            root.path().join("app/project.json"),
431            json!({
432                "name": "app", "targets": {"build": {"command": "echo build", "inputs": inputs}}
433            })
434            .to_string(),
435        )
436        .unwrap();
437        std::fs::write(root.path().join("app/meta.json"), r#"{"version":1}"#).unwrap();
438        if let Some(lockfile) = lockfile {
439            std::fs::write(root.path().join("pnpm-lock.yaml"), lockfile).unwrap();
440            std::fs::write(
441                root.path().join("pnpm-workspace.yaml"),
442                "packages:\n  - app\n",
443            )
444            .unwrap();
445        }
446        let workspace = Workspace::load(root.path()).unwrap();
447        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
448        (root, workspace, graph)
449    }
450
451    #[test]
452    fn dependency_manifests_change_by_what_they_decide_for_dependents() {
453        let root = tempfile::tempdir().unwrap();
454        let path = "lib/package.json";
455        let inputs = |lockfile: bool| Resolved {
456            files: BTreeSet::new(),
457            mandatory: BTreeSet::from([path.to_owned()]),
458            values: BTreeMap::from([(format!("manifest:{path}"), Value::Null)]),
459            lockfile: lockfile.then(|| (BTreeSet::new(), BTreeSet::new())),
460            workspace_file: false,
461            external: BTreeSet::new(),
462        };
463        let changed = vec![path.to_owned()];
464        let reasons = |before: Option<&str>, after: Option<&str>, lockfile: bool| {
465            InputChanges::new(root.path(), &changed, None, false)
466                .unwrap()
467                .with_manifests(BTreeMap::from([(
468                    path.to_owned(),
469                    (before.map(String::from), after.map(String::from)),
470                )]))
471                .reasons(&inputs(lockfile))
472                .unwrap()
473        };
474        let input = [InputChange::Input { file: path.into() }];
475        let base = r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^1"}}"#;
476        assert!(
477            reasons(
478                Some(base),
479                Some(r#"{"name":"lib","version":"2.0.0","dependencies":{"a":"^1"}}"#),
480                false
481            )
482            .is_empty()
483        );
484        assert_eq!(
485            reasons(
486                Some(base),
487                Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
488                false
489            ),
490            input
491        );
492        assert!(
493            reasons(
494                Some(base),
495                Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
496                true
497            )
498            .is_empty()
499        );
500        assert_eq!(
501            reasons(Some(base), Some(r#"{"name":"other"}"#), true),
502            input
503        );
504        // Added, deleted or unreadable manifests count as changed.
505        assert_eq!(reasons(None, Some(base), true), input);
506        assert_eq!(reasons(Some(base), None, true), input);
507        assert_eq!(reasons(Some("{"), Some("{"), true), input);
508        // So does one whose revisions were not read.
509        assert_eq!(
510            InputChanges::new(root.path(), &changed, None, false)
511                .unwrap()
512                .reasons(&inputs(true))
513                .unwrap(),
514            input
515        );
516    }
517
518    #[test]
519    fn shared_declarations_do_not_execute_runtime_inputs_or_read_environment_values() {
520        let (root, workspace, graph) = workspace(
521            json!([
522                {"json": "{projectRoot}/meta.json", "fields": ["version"]},
523                {"env": "PATH"}, {"runtime": "echo evaluated > runtime-marker"},
524                {"dependentTasksOutputFiles": "**/*.js", "transitive": true}
525            ]),
526            None,
527        );
528        let changed = vec!["runtime-marker".into(), "app/meta.json".into()];
529        let resolved = resolve_tasks(&workspace, &graph, &changed).unwrap();
530        let inputs = &resolved["app:build"];
531        assert_eq!(inputs.json_inputs().collect::<Vec<_>>(), ["app/meta.json"]);
532        assert_eq!(inputs.environment_inputs().collect::<Vec<_>>(), ["PATH"]);
533        assert_eq!(
534            inputs.runtime_inputs().collect::<Vec<_>>(),
535            ["echo evaluated > runtime-marker"]
536        );
537        assert_eq!(inputs.values["env:PATH"], Value::Null);
538        assert_eq!(
539            inputs.values["runtime:echo evaluated > runtime-marker"],
540            Value::Null
541        );
542        assert_eq!(
543            inputs.dependency_output_inputs().collect::<Vec<_>>(),
544            [("**/*.js", true)]
545        );
546        assert!(inputs.file_inputs().any(|path| path == "app/meta.json"));
547        assert_eq!(
548            InputChanges::new(root.path(), &changed, None, false)
549                .unwrap()
550                .reasons(inputs)
551                .unwrap(),
552            [InputChange::Input {
553                file: "app/meta.json".into()
554            }]
555        );
556        assert!(!root.path().join("runtime-marker").exists());
557        let each = resolve_each(&workspace, &graph).unwrap();
558        assert_eq!(
559            each.tasks["app:build"].as_ref().unwrap().values,
560            inputs.values
561        );
562        assert!(!root.path().join("runtime-marker").exists());
563    }
564
565    fn lockfile(app: &str, other: &str) -> String {
566        let versions = BTreeSet::from([app, other]);
567        let packages: String = versions
568            .iter()
569            .map(|version| {
570                format!("  react@{version}:\n    resolution: {{integrity: sha512-{version}}}\n")
571            })
572            .collect();
573        let snapshots: String = versions
574            .iter()
575            .map(|version| format!("  react@{version}: {{}}\n"))
576            .collect();
577        format!(
578            "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}"
579        )
580    }
581
582    #[test]
583    fn installation_changes_preserve_selectivity_fallback_and_reason_order() {
584        let before_text = lockfile("1.0.0", "1.0.0");
585        let (root, workspace, graph) = workspace(
586            json!([
587                {"json": "{projectRoot}/meta.json"}, {"externalDependencies": ["react"]}
588            ]),
589            Some(&before_text),
590        );
591        let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
592        let inputs = &resolved["app:build"];
593        assert!(inputs.is_resolution_metadata("pnpm-lock.yaml"));
594        assert!(inputs.is_resolution_metadata("pnpm-workspace.yaml"));
595        let before = Lockfile::parse(&before_text).unwrap();
596        let unrelated = Lockfile::parse(&lockfile("1.0.0", "2.0.0")).unwrap();
597        let changed = vec![
598            "pnpm-workspace.yaml".into(),
599            "pnpm-lock.yaml".into(),
600            "app/meta.json".into(),
601        ];
602        let mut changes =
603            InputChanges::new(root.path(), &changed, Some((&before, &unrelated)), true).unwrap();
604        // A named external package sees every importer; the task's own importer
605        // is unchanged, so this must not report an installation change for app.
606        assert_eq!(
607            changes.reasons(inputs).unwrap(),
608            [
609                InputChange::Input {
610                    file: "app/meta.json".into()
611                },
612                InputChange::WorkspaceFile,
613                InputChange::Package {
614                    name: "react".into()
615                },
616            ]
617        );
618        let after = Lockfile::parse(
619            &(lockfile("2.0.0", "2.0.0") + "settings:\n  autoInstallPeers: false\n"),
620        )
621        .unwrap();
622        assert_eq!(
623            InputChanges::new(root.path(), &changed, Some((&before, &after)), true)
624                .unwrap()
625                .reasons(inputs)
626                .unwrap(),
627            [
628                InputChange::Input {
629                    file: "app/meta.json".into()
630                },
631                InputChange::WorkspaceFile,
632                InputChange::Lockfile,
633                InputChange::Installs {
634                    importer: "app".into()
635                },
636                InputChange::Package {
637                    name: "react".into()
638                },
639            ]
640        );
641        assert_eq!(
642            InputChanges::new(root.path(), &changed, None, false)
643                .unwrap()
644                .reasons(inputs)
645                .unwrap(),
646            [
647                InputChange::Input {
648                    file: "app/meta.json".into()
649                },
650                InputChange::Input {
651                    file: "pnpm-lock.yaml".into()
652                },
653            ]
654        );
655    }
656}