Skip to main content

qk_cache/
inputs.rs

1//! Task inputs shared by cache keys, revision-change selection and input
2//! 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 inputs, before cache-key evaluation: what its declared inputs
18/// select, and what qk adds to every task on its own.
19pub struct Resolved {
20    pub declared: Declared,
21    pub inferred: Inferred,
22}
23
24/// What a task's `inputs` select, each kept as it says: files by their whole
25/// content and JSON inputs by their selected fields, whatever the file is.
26#[derive(Default)]
27pub struct Declared {
28    /// Workspace-relative files whose content is part of the key.
29    pub files: BTreeSet<String>,
30    /// The files among them an `includeIgnored` fileset read from disk, keyed
31    /// by the content they point to, wherever that is, as Nx keys them.
32    pub ignored: BTreeSet<String>,
33    /// Env, runtime and other named values; env and runtime are evaluated only
34    /// with a prepared task.
35    pub values: BTreeMap<String, Value>,
36    /// The fields JSON inputs select, by file, so that a change can be judged
37    /// by them: each distinct selection, when several inputs read one file.
38    pub json_fields: BTreeMap<String, Vec<JsonFields>>,
39    /// Packages named by `externalDependencies`.
40    pub external: BTreeSet<String>,
41    /// Whether any `externalDependencies` input is declared, even an empty
42    /// one. Without one, Nx hashes every installed package into the task.
43    pub external_declared: bool,
44    /// The projects whose configuration and root tsconfig paths Nx hashes
45    /// into the task: its own, and each project whose inputs it reads, such
46    /// as a dependency through `^production` or a project a continuous task
47    /// serving it belongs to.
48    pub projects: BTreeSet<String>,
49    /// The tasks whose declared outputs these inputs read, through
50    /// `dependentTasksOutputFiles` or a fileset selecting them. As in Nx, a
51    /// change carries from such a task to this one, and from no other.
52    pub producers: BTreeSet<String>,
53}
54
55/// What qk adds to every task's key on its own, each part by its own rule.
56/// A file the declared inputs already select is left out here, since the
57/// declared entry counts all of it.
58#[derive(Default)]
59pub struct Inferred {
60    /// Workspace configuration and the task's own manifests, by their whole
61    /// content.
62    pub files: BTreeSet<String>,
63    /// nx.json, the file it extends and nx.local.json, keyed together by
64    /// their merged settings without those that reach tasks only through
65    /// their resolved configuration or change no task's work: see
66    /// [`qk_config::WorkspaceConfig::keyed_settings`].
67    pub settings: BTreeSet<String>,
68    /// The task's project's `project.json` and `project.local.json`, keyed
69    /// by what they decide for the task beside its definition and resolved
70    /// inputs: see [`crate::task_configuration`].
71    pub project: BTreeSet<String>,
72    /// `pnpm-workspace.yaml` without its resolution keys, which reach tasks
73    /// through the lockfile instead.
74    pub workspace_file: bool,
75    /// With a readable pnpm lockfile: the importers whose installs count.
76    pub lockfile: Option<BTreeSet<String>>,
77    /// Whether qk can read the pnpm lockfile, so that what it records is left
78    /// out of manifests, and `externalDependencies` count by their installs.
79    pub lockfile_readable: bool,
80    /// Dependency manifests, each as its dependents' keys read it. See
81    /// [`manifest_for_dependents`].
82    pub manifests: BTreeMap<String, Value>,
83    /// The root tsconfig, keyed as Nx hashes it: without
84    /// `compilerOptions.paths`, and with `selectivelyHashTsConfig` the paths
85    /// entries pointing into the task's [`Declared::projects`].
86    pub tsconfig: Option<String>,
87    /// Files qk would add that the declared inputs already select.
88    pub covered: BTreeSet<String>,
89}
90
91/// What a `json` input keys of its file.
92#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
93pub struct JsonFields {
94    /// Only these dotted fields, or all of them.
95    pub fields: Option<Vec<String>>,
96    /// Without these dotted fields.
97    pub exclude: Vec<String>,
98}
99
100impl Inferred {
101    /// Every file this part reads, whichever rule it counts by.
102    pub fn paths(&self) -> impl Iterator<Item = &str> {
103        self.files
104            .iter()
105            .chain(&self.settings)
106            .chain(&self.project)
107            .map(String::as_str)
108            .chain(self.workspace_file.then_some("pnpm-workspace.yaml"))
109            .chain(self.lockfile.is_some().then_some("pnpm-lock.yaml"))
110            .chain(self.manifests.keys().map(String::as_str))
111            .chain(self.tsconfig.as_deref())
112    }
113
114    /// Package-manager files keyed by what they resolve to rather than by
115    /// their content.
116    pub fn is_resolution_metadata(&self, path: &str) -> bool {
117        (path == "pnpm-lock.yaml" && self.lockfile.is_some())
118            || (path == "pnpm-workspace.yaml" && self.workspace_file)
119    }
120}
121
122/// The root tsconfig files, in the order Nx picks one.
123pub const ROOT_TSCONFIG_FILES: [&str; 2] = ["tsconfig.base.json", "tsconfig.json"];
124
125/// Whether nx.json's runner options set `selectivelyHashTsConfig`, which Nx
126/// reads from the runner `--runner` or `NX_TASKS_RUNNER` names when nx.json
127/// configures it, or else from `default`.
128pub fn selectively_hash_tsconfig(workspace: &Workspace) -> bool {
129    let runners = workspace.config.extra.get("tasksRunnerOptions");
130    let runner = std::env::var("NX_TASKS_RUNNER")
131        .ok()
132        .filter(|runner| runners.is_some_and(|runners| runners.get(runner).is_some()))
133        .unwrap_or_else(|| "default".into());
134    runners
135        .and_then(|runners| runners.get(&runner))
136        .and_then(|runner| runner.get("options"))
137        .and_then(|options| options.get("selectivelyHashTsConfig"))
138        .and_then(Value::as_bool)
139        .unwrap_or(false)
140}
141
142/// A root tsconfig split as Nx hashes it: everything but
143/// `compilerOptions.paths`, and the paths apart. `None` when it cannot be
144/// read as JSON or JSONC, or a paths entry is not a list of strings.
145pub struct TsConfig {
146    pub rest: Value,
147    pub paths: BTreeMap<String, Vec<String>>,
148}
149
150impl TsConfig {
151    pub fn parse(text: &str) -> Option<Self> {
152        let mut rest: Value = jsonc_parser::parse_to_serde_value(text, &Default::default()).ok()?;
153        let mut paths = BTreeMap::new();
154        if let Some(options) = rest
155            .as_object_mut()?
156            .get_mut("compilerOptions")
157            .and_then(Value::as_object_mut)
158            && let Some(removed) = options.remove("paths")
159        {
160            paths = serde_json::from_value(removed).ok()?;
161        }
162        Some(Self { rest, paths })
163    }
164
165    /// The paths entries Nx's `hash_tsconfig_selectively` keeps for
166    /// `projects`: each key with its targets that one of them owns, `owner`
167    /// telling a target's project.
168    pub fn paths_of<'a>(
169        &self,
170        projects: &BTreeSet<String>,
171        owner: impl Fn(&str) -> Option<&'a str>,
172    ) -> BTreeMap<String, Vec<String>> {
173        self.paths
174            .iter()
175            .filter_map(|(key, targets)| {
176                let owned: Vec<String> = targets
177                    .iter()
178                    .filter(|target| owner(target).is_some_and(|owner| projects.contains(owner)))
179                    .cloned()
180                    .collect();
181                (!owned.is_empty()).then(|| (key.clone(), owned))
182            })
183            .collect()
184    }
185}
186
187impl Resolved {
188    /// The files keyed by their whole content, declared or inferred.
189    pub fn whole_files(&self) -> impl Iterator<Item = &str> {
190        self.declared
191            .files
192            .iter()
193            .chain(&self.inferred.files)
194            .map(String::as_str)
195    }
196
197    /// Whether `path` is keyed by its whole content.
198    pub fn is_whole_file(&self, path: &str) -> bool {
199        self.declared.files.contains(path) || self.inferred.files.contains(path)
200    }
201
202    /// Whole files and the files of JSON inputs, for conservative change
203    /// selection.
204    pub fn file_inputs(&self) -> impl Iterator<Item = &str> {
205        self.whole_files().chain(self.json_inputs())
206    }
207
208    /// Every file qk adds on its own, including those the declared inputs
209    /// also select.
210    pub fn always_included(&self) -> BTreeSet<String> {
211        self.inferred
212            .paths()
213            .map(str::to_owned)
214            .chain(self.inferred.covered.iter().cloned())
215            .collect()
216    }
217
218    /// Whether `other`, the same task's inputs resolved from another
219    /// configuration of the same files, selects what these do for its key:
220    /// the same files, values and external packages, the same producers,
221    /// whose outputs the key holds once they are written, the same projects
222    /// when `selectivelyHashTsConfig` keys their root tsconfig paths, and the
223    /// same files, manifests and importers qk adds, which the project graph
224    /// and declaring `externalDependencies` decide. Values hold what `json`
225    /// inputs select and `dependentTasksOutputFiles` globs; env and runtime
226    /// inputs compare by name.
227    pub fn selects_alike(&self, other: &Resolved, workspace: &Workspace) -> bool {
228        let (one, two) = (&self.declared, &other.declared);
229        let (mine, theirs) = (&self.inferred, &other.inferred);
230        one.files == two.files
231            && one.ignored == two.ignored
232            && one.values == two.values
233            && one.external == two.external
234            && one.producers == two.producers
235            && (!selectively_hash_tsconfig(workspace) || one.projects == two.projects)
236            && mine.files == theirs.files
237            && mine.manifests.keys().eq(theirs.manifests.keys())
238            && mine.lockfile == theirs.lockfile
239            && mine.tsconfig == theirs.tsconfig
240            && mine.workspace_file == theirs.workspace_file
241    }
242
243    pub fn json_inputs(&self) -> impl Iterator<Item = &str> {
244        self.value_names("json:")
245    }
246
247    pub fn is_json_input(&self, path: &str) -> bool {
248        self.declared.values.contains_key(&format!("json:{path}"))
249    }
250
251    pub fn environment_inputs(&self) -> impl Iterator<Item = &str> {
252        self.value_names("env:")
253    }
254
255    pub fn runtime_inputs(&self) -> impl Iterator<Item = &str> {
256        self.value_names("runtime:")
257    }
258
259    /// Each dependency-output pattern and whether it includes transitive tasks.
260    pub fn dependency_output_inputs(&self) -> impl Iterator<Item = (&str, bool)> {
261        self.declared
262            .values
263            .iter()
264            .filter_map(|(name, transitive)| {
265                name.strip_prefix("dependentTasksOutputFiles:")
266                    .map(|pattern| (pattern, transitive == &Value::Bool(true)))
267            })
268    }
269
270    fn value_names<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a str> {
271        self.declared
272            .values
273            .keys()
274            .filter_map(move |name| name.strip_prefix(prefix))
275    }
276}
277
278/// Each task's resolved inputs, for affected selection. `extra` paths are
279/// candidates beside the workspace's files, so a deleted file still matches
280/// the inputs that named it. Env and runtime inputs are not evaluated.
281pub fn resolve_tasks(
282    workspace: &Workspace,
283    graph: &TaskGraph,
284    extra: &[String],
285) -> Result<BTreeMap<String, Resolved>> {
286    let cache = paths::cache_location(workspace);
287    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
288    let cancelled = AtomicBool::new(false);
289    graph
290        .tasks
291        .iter()
292        .map(|(id, task)| {
293            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
294                .with_context(|| format!("cannot resolve the inputs of {id}"))?;
295            Ok((id.clone(), resolved))
296        })
297        .collect()
298}
299
300/// Every task's resolved inputs, or why they could not be resolved, with the
301/// workspace files they were chosen from.
302pub struct Resolution {
303    pub candidates: std::collections::BTreeSet<String>,
304    pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
305}
306
307pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
308    let cache = paths::cache_location(workspace);
309    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
310    let cancelled = AtomicBool::new(false);
311    let tasks = graph
312        .tasks
313        .iter()
314        .map(|(id, task)| {
315            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
316                .map_err(|error| format!("{error:#}"));
317            (id.clone(), resolved)
318        })
319        .collect();
320    Ok(Resolution {
321        candidates: snapshot.files.clone(),
322        tasks,
323    })
324}
325
326/// A changed input's meaning, independent of revision loading and task reports.
327#[derive(Debug, PartialEq, Eq)]
328pub enum InputChange {
329    Input { file: String },
330    Installs { importer: String },
331    Package { name: String },
332    Lockfile,
333    WorkspaceFile,
334}
335
336/// Matches one revision change set against any number of resolved tasks, sharing
337/// linked-path traversal across tasks. Env and runtime values are not evaluated;
338/// the caller supplies lockfile revisions and the workspace-file comparison.
339pub struct InputChanges<'a> {
340    root: PathBuf,
341    files: &'a [String],
342    changed: BTreeSet<&'a str>,
343    lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
344    workspace_file_changed: bool,
345    /// Changed manifests' base and head texts, `None` where absent.
346    manifests: BTreeMap<String, (Option<String>, Option<String>)>,
347    /// Changed JSON inputs' base and head texts, `None` where absent.
348    json: BTreeMap<String, (Option<String>, Option<String>)>,
349    links: BTreeMap<String, Vec<String>>,
350    directory_links: BTreeMap<PathBuf, Vec<PathBuf>>,
351}
352
353impl<'a> InputChanges<'a> {
354    /// `files` are workspace-relative changes. `lockfiles` are the readable base
355    /// and head revisions when the lockfile changed, or `None` otherwise;
356    /// `workspace_file_changed` excludes installation-resolution settings.
357    pub fn new(
358        root: &Path,
359        files: &'a [String],
360        lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
361        workspace_file_changed: bool,
362    ) -> Result<Self> {
363        Ok(Self {
364            root: root.canonicalize()?,
365            files,
366            changed: files.iter().map(String::as_str).collect(),
367            lockfiles,
368            workspace_file_changed,
369            manifests: BTreeMap::new(),
370            json: BTreeMap::new(),
371            links: BTreeMap::new(),
372            directory_links: BTreeMap::new(),
373        })
374    }
375
376    /// The base and head texts of changed manifests, so that a dependency's
377    /// manifest counts by what it decides for dependents. A changed manifest
378    /// without them counts as changed.
379    pub fn with_manifests(
380        mut self,
381        manifests: BTreeMap<String, (Option<String>, Option<String>)>,
382    ) -> Self {
383        self.manifests = manifests;
384        self
385    }
386
387    /// The base and head texts of changed files that JSON inputs select
388    /// fields of, so that such a change counts only when a selected field
389    /// changed. A changed JSON input without them counts as changed.
390    pub fn with_json(mut self, json: BTreeMap<String, (Option<String>, Option<String>)>) -> Self {
391        self.json = json;
392        self
393    }
394
395    /// Sorted file reasons, then workspace metadata and installation reasons,
396    /// preserving the order used in affected-task reports.
397    pub fn reasons(&mut self, inputs: &Resolved) -> Result<Vec<InputChange>> {
398        let mut reasons = Vec::new();
399        let mut touched = BTreeSet::new();
400        for file in inputs.file_inputs() {
401            if self.changed.contains(file) {
402                // A file the inputs also select whole counts by all of it.
403                if inputs.is_whole_file(file) || !self.json_unchanged(inputs, file) {
404                    touched.insert(file);
405                }
406                continue;
407            }
408            if !self.links.contains_key(file) {
409                self.links.insert(
410                    file.to_owned(),
411                    symlink_targets(&self.root, file, &mut self.directory_links)?,
412                );
413            }
414            for target in &self.links[file] {
415                touched.extend(
416                    self.files
417                        .iter()
418                        .filter(|changed| {
419                            target.is_empty()
420                                || **changed == *target
421                                || changed
422                                    .strip_prefix(target)
423                                    .is_some_and(|suffix| suffix.starts_with('/'))
424                        })
425                        .map(String::as_str),
426                );
427            }
428        }
429        let lockfile = inputs.inferred.lockfile_readable;
430        for file in inputs.inferred.manifests.keys().map(String::as_str) {
431            if !self.changed.contains(file) {
432                continue;
433            }
434            let unchanged = self.manifests.get(file).is_some_and(|(before, after)| {
435                let read = |text: &Option<String>| {
436                    text.as_deref()
437                        .and_then(|text| manifest_for_dependents(text, lockfile))
438                };
439                let before = read(before);
440                before.is_some() && before == read(after)
441            });
442            if !unchanged {
443                touched.insert(file);
444            }
445        }
446        for file in touched {
447            reasons.push(InputChange::Input {
448                file: file.to_owned(),
449            });
450        }
451        if inputs.inferred.workspace_file && self.workspace_file_changed {
452            reasons.push(InputChange::WorkspaceFile);
453        }
454        // A lockfile keyed whole has counted as an input above.
455        let Some(importers) = &inputs.inferred.lockfile else {
456            return Ok(reasons);
457        };
458        match &self.lockfiles {
459            Some((before, after)) => {
460                if before.global() != after.global() {
461                    reasons.push(InputChange::Lockfile);
462                }
463                for importer in importers {
464                    if before.installed(importer) != after.installed(importer) {
465                        reasons.push(InputChange::Installs {
466                            importer: importer.clone(),
467                        });
468                    }
469                }
470                for name in &inputs.declared.external {
471                    if before.package(name) != after.package(name) {
472                        reasons.push(InputChange::Package { name: name.clone() });
473                    }
474                }
475            }
476            None if self.changed.contains("pnpm-lock.yaml") => {
477                reasons.push(InputChange::Input {
478                    file: "pnpm-lock.yaml".into(),
479                });
480            }
481            None => {}
482        }
483        Ok(reasons)
484    }
485}
486
487impl InputChanges<'_> {
488    /// Whether `file` is a JSON input whose selected fields are the same at
489    /// both revisions.
490    fn json_unchanged(&self, inputs: &Resolved, file: &str) -> bool {
491        let (Some(selections), Some((before, after))) =
492            (inputs.declared.json_fields.get(file), self.json.get(file))
493        else {
494            return false;
495        };
496        selections.iter().all(|fields| {
497            let read = |text: &Option<String>| {
498                text.as_deref()
499                    .and_then(|text| hash::json_projection(file, text, fields))
500            };
501            let before = read(before);
502            before.is_some() && before == read(after)
503        })
504    }
505}
506
507/// Manifest fields that cannot change what a dependent task reads or runs: a
508/// dependency's scripts run only as its own tasks, and the rest is metadata
509/// that resolvers and bundlers do not read. A version can still add or remove
510/// a dependency through the range a dependent declares; that changes the
511/// project graph, and the dependent's key with it.
512const DEPENDENT_IGNORED_FIELDS: &[&str] = &[
513    "version",
514    "scripts",
515    "description",
516    "keywords",
517    "author",
518    "contributors",
519    "maintainers",
520    "license",
521    "homepage",
522    "repository",
523    "bugs",
524    "funding",
525    "private",
526    "publishConfig",
527];
528
529/// Dependency declarations, whose effect reaches dependents through the
530/// lockfile's record of what each importer installs.
531const DECLARATION_FIELDS: &[&str] = &[
532    "dependencies",
533    "devDependencies",
534    "peerDependencies",
535    "peerDependenciesMeta",
536    "optionalDependencies",
537    "dependenciesMeta",
538];
539
540/// A dependency's `package.json` as its dependents' keys read it: without
541/// fields that cannot change what they read or run, and, when the lockfile
542/// records the installs, without the declarations it resolved. `None` when it
543/// cannot be read as a JSON object.
544pub fn manifest_for_dependents(text: &str, lockfile: bool) -> Option<Value> {
545    let mut value: Value = serde_json::from_str(text).ok()?;
546    let object = value.as_object_mut()?;
547    object.retain(|key, _| {
548        !DEPENDENT_IGNORED_FIELDS.contains(&key.as_str())
549            && !(lockfile && DECLARATION_FIELDS.contains(&key.as_str()))
550    });
551    Some(value)
552}
553
554/// `pnpm-workspace.yaml` without the keys that configure resolution, or `None`
555/// when it cannot be read as YAML.
556pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
557    let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
558    without_resolution(&text)
559}
560
561/// A `pnpm-workspace.yaml` text without its resolution keys.
562pub fn without_resolution(text: &str) -> Option<Value> {
563    let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
564    if let Some(object) = value.as_object_mut() {
565        object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
566    }
567    Some(value)
568}
569
570/// Paths a selected symlink reads, including a target that has been deleted.
571fn symlink_targets(
572    root: &Path,
573    path: &str,
574    directory_links: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
575) -> Result<Vec<String>> {
576    let mut pending = vec![root.join(path)];
577    let mut visited = BTreeSet::new();
578    let mut targets = BTreeSet::new();
579    while let Some(absolute) = pending.pop() {
580        if !visited.insert(absolute.clone()) {
581            continue;
582        }
583        let metadata = match std::fs::symlink_metadata(&absolute) {
584            Ok(metadata) => metadata,
585            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
586            Err(error) => return Err(error.into()),
587        };
588        if !metadata.file_type().is_symlink() {
589            continue;
590        }
591        let target = absolute
592            .parent()
593            .expect("input has a parent")
594            .join(std::fs::read_link(&absolute)?);
595        let mut normalized = PathBuf::new();
596        for component in target.components() {
597            match component {
598                Component::CurDir => {}
599                Component::ParentDir => {
600                    normalized.pop();
601                }
602                component => normalized.push(component.as_os_str()),
603            }
604        }
605        let resolved = target.canonicalize().ok().or_else(|| {
606            Some(
607                target
608                    .parent()?
609                    .canonicalize()
610                    .ok()?
611                    .join(target.file_name()?),
612            )
613        });
614        if normalized.starts_with(root) {
615            pending.push(normalized.clone());
616        }
617        if let Some(resolved) = &resolved
618            && resolved.starts_with(root)
619            && resolved.is_dir()
620        {
621            if !directory_links.contains_key(resolved) {
622                let mut links = Vec::new();
623                for entry in walkdir::WalkDir::new(resolved).follow_links(false) {
624                    let entry = entry?;
625                    if entry.file_type().is_symlink() {
626                        links.push(entry.into_path());
627                    }
628                }
629                directory_links.insert(resolved.clone(), links);
630            }
631            pending.extend(directory_links[resolved].iter().cloned());
632        }
633        for target in std::iter::once(normalized).chain(resolved) {
634            if let Ok(relative) = target.strip_prefix(root)
635                && let Some(relative) = relative.to_str()
636            {
637                targets.insert(relative.replace('\\', "/"));
638            }
639        }
640    }
641    Ok(targets.into_iter().collect())
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use qk_taskgraph::Request;
648    use serde_json::json;
649
650    fn workspace(
651        inputs: Value,
652        lockfile: Option<&str>,
653    ) -> (tempfile::TempDir, Workspace, TaskGraph) {
654        let root = tempfile::tempdir().unwrap();
655        std::fs::create_dir(root.path().join("app")).unwrap();
656        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
657        std::fs::write(
658            root.path().join("app/project.json"),
659            json!({
660                "name": "app", "targets": {"build": {"command": "echo build", "inputs": inputs}}
661            })
662            .to_string(),
663        )
664        .unwrap();
665        std::fs::write(root.path().join("app/meta.json"), r#"{"version":1}"#).unwrap();
666        if let Some(lockfile) = lockfile {
667            std::fs::write(root.path().join("pnpm-lock.yaml"), lockfile).unwrap();
668            std::fs::write(
669                root.path().join("pnpm-workspace.yaml"),
670                "packages:\n  - app\n",
671            )
672            .unwrap();
673        }
674        let workspace = Workspace::load(root.path()).unwrap();
675        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
676        (root, workspace, graph)
677    }
678
679    /// The files `app:build` declares with `inputs`, in a workspace where
680    /// `app` depends on `lib` and both hold a few files.
681    fn declared(inputs: Value) -> Result<BTreeSet<String>> {
682        declared_with(inputs, &[])
683    }
684
685    fn declared_with(inputs: Value, extra: &[&str]) -> Result<BTreeSet<String>> {
686        let root = tempfile::tempdir().unwrap();
687        for file in extra {
688            let path = root.path().join(file);
689            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
690            std::fs::write(path, "").unwrap();
691        }
692        std::fs::write(root.path().join(".gitignore"), "dist/\n").unwrap();
693        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
694        for (path, text) in [
695            (
696                "app/project.json",
697                json!({"name": "app", "implicitDependencies": ["lib"],
698                    "namedInputs": {"production": ["{projectRoot}/src/**/*"]},
699                    "targets": {"build": {"command": "true", "inputs": inputs}}})
700                .to_string(),
701            ),
702            ("lib/project.json", json!({"name": "lib"}).to_string()),
703            ("app/src/main.ts", String::new()),
704            ("app/src/app/(group)/page.ts", String::new()),
705            ("app/src/app/group/page.ts", String::new()),
706            ("app/app/notes.md", String::new()),
707            ("app/workspaceRoot/x.ts", String::new()),
708            ("lib/src/index.ts", String::new()),
709            ("lib/lib.ts", String::new()),
710            ("tools/gen.ts", String::new()),
711        ] {
712            let path = root.path().join(path);
713            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
714            std::fs::write(path, text).unwrap();
715        }
716        let workspace = Workspace::load(root.path()).unwrap();
717        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
718        let mut resolved = resolve_tasks(&workspace, &graph, &[])?;
719        Ok(resolved.remove("app:build").unwrap().declared.files)
720    }
721
722    fn files(paths: &[&str]) -> BTreeSet<String> {
723        paths.iter().map(|path| (*path).to_owned()).collect()
724    }
725
726    #[test]
727    fn dependency_filesets_are_written_with_a_caret() {
728        assert_eq!(
729            declared(json!([
730                "^{projectRoot}/src/**/*",
731                "^{workspaceRoot}/tools/**/*"
732            ]))
733            .unwrap(),
734            files(&["lib/src/index.ts", "tools/gen.ts"])
735        );
736        // Only the string spelling takes the caret.
737        assert!(declared(json!([{"fileset": "^{projectRoot}/src/**/*"}])).is_err());
738    }
739
740    #[test]
741    fn filesets_read_the_project_name_and_workspace_root_only_at_the_start() {
742        assert_eq!(
743            declared(json!(["{workspaceRoot}/{projectName}/src/main.ts"])).unwrap(),
744            files(&["app/src/main.ts"])
745        );
746        // Elsewhere `{workspaceRoot}` is glob text: a group of one name.
747        assert_eq!(
748            declared(json!(["{projectRoot}/{workspaceRoot}/x.ts"])).unwrap(),
749            files(&["app/workspaceRoot/x.ts"])
750        );
751        // A string that is no named input must be a fileset from a root.
752        assert!(declared(json!(["src/**/*"])).is_err());
753        assert_eq!(declared(json!(["production"])).unwrap().len(), 3);
754    }
755
756    #[test]
757    fn a_fileset_naming_a_directory_selects_everything_under_it() {
758        let src = files(&[
759            "app/src/main.ts",
760            "app/src/app/(group)/page.ts",
761            "app/src/app/group/page.ts",
762        ]);
763        for fileset in [
764            "{projectRoot}/src",
765            "{projectRoot}/src/",
766            "{projectRoot}/src/**/*",
767        ] {
768            assert_eq!(declared(json!([fileset])).unwrap(), src, "{fileset}");
769        }
770        assert_eq!(
771            declared(json!(["{projectRoot}/**/*", "!{projectRoot}/src"])).unwrap(),
772            files(&[
773                "app/app/notes.md",
774                "app/project.json",
775                "app/workspaceRoot/x.ts"
776            ])
777        );
778    }
779
780    #[test]
781    fn a_backslash_escapes_a_fileset_character() {
782        assert_eq!(
783            declared(json!([r"{projectRoot}/src/app/\(group\)/**/*"])).unwrap(),
784            files(&["app/src/app/(group)/page.ts"])
785        );
786        assert_eq!(
787            declared(json!([r"{projectRoot}/src/app/\(group\)"])).unwrap(),
788            files(&["app/src/app/(group)/page.ts"])
789        );
790    }
791
792    #[test]
793    fn json_inputs_can_select_several_files() {
794        let root = tempfile::tempdir().unwrap();
795        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
796        for (path, text) in [
797            (
798                "app/project.json",
799                json!({"name": "app", "targets": {"build": {"command": "true",
800                    "inputs": [{"json": "{projectRoot}/config/*.json", "fields": ["a"]},
801                        {"json": "{workspaceRoot}/shared/**/*.json"}]}}})
802                .to_string(),
803            ),
804            ("app/config/one.json", r#"{"a": 1, "b": 2}"#.to_owned()),
805            ("app/config/two.json", r#"{"a": 3}"#.to_owned()),
806            ("app/config/notes.txt", String::new()),
807            (
808                "app/config/nested/project.json",
809                json!({"name": "nested"}).to_string(),
810            ),
811            ("app/config/nested/x.json", "{}".to_owned()),
812            ("shared/deep/s.json", r#"{"s": true}"#.to_owned()),
813        ] {
814            let path = root.path().join(path);
815            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
816            std::fs::write(path, text).unwrap();
817        }
818        let workspace = Workspace::load(root.path()).unwrap();
819        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
820        let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
821        let declared = &resolved["app:build"].declared;
822        assert_eq!(
823            declared.values,
824            BTreeMap::from([
825                ("json:app/config/one.json".to_owned(), json!({"a": 1})),
826                ("json:app/config/two.json".to_owned(), json!({"a": 3})),
827                ("json:shared/deep/s.json".to_owned(), json!({"s": true})),
828            ])
829        );
830    }
831
832    #[test]
833    fn include_ignored_groups_filter_their_own_positives() {
834        let dist = [
835            "lib/dist/index.js",
836            "lib/dist/index.js.map",
837            "app/dist/main.js",
838        ];
839        let dependencies = |fileset: &str| json!({"fileset": fileset, "includeIgnored": true, "dependencies": true});
840        assert_eq!(
841            declared_with(
842                json!([
843                    dependencies("{projectRoot}/dist/**"),
844                    dependencies("!{projectRoot}/dist/**/*.map"),
845                    {"fileset": "{projectRoot}/dist", "includeIgnored": true},
846                ]),
847                &dist
848            )
849            .unwrap(),
850            files(&["app/dist/main.js", "lib/dist/index.js"])
851        );
852        // A negation filters only the group it is resolved with.
853        let error = declared_with(
854            json!([
855                dependencies("{projectRoot}/dist/**"),
856                {"fileset": "!{projectRoot}/dist/**/*.map", "includeIgnored": true},
857            ]),
858            &dist,
859        )
860        .unwrap_err();
861        assert!(
862            format!("{error:#}").contains("no positive includeIgnored fileset"),
863            "{error:#}"
864        );
865        // Without the flag, the ignored directory selects nothing.
866        assert!(
867            declared_with(json!(["{projectRoot}/dist/**"]), &dist)
868                .unwrap()
869                .is_empty()
870        );
871    }
872
873    #[test]
874    fn always_inputs_must_select_a_project() {
875        let error =
876            declared(json!([{"input": "default", "projects": ["lib", "missing"], "always": true}]))
877                .unwrap_err();
878        assert!(
879            format!("{error:#}").contains("\"missing\", which matches no project"),
880            "{error:#}"
881        );
882        assert!(declared(json!([{"input": "default", "projects": ["missing"]}])).is_ok());
883    }
884
885    #[test]
886    fn external_dependencies_cannot_name_a_project() {
887        let error = declared(json!([{"externalDependencies": ["lib"]}])).unwrap_err();
888        assert!(
889            format!("{error:#}").contains("already a dependency"),
890            "{error:#}"
891        );
892    }
893
894    #[test]
895    fn declared_inputs_cover_the_files_qk_would_add() {
896        let (_root, loaded, graph) = workspace(
897            json!([
898                "{projectRoot}/meta.json",
899                "{workspaceRoot}/nx.json",
900                "{workspaceRoot}/pnpm-lock.yaml"
901            ]),
902            Some(&lockfile("1.0.0", "1.0.0")),
903        );
904        let resolved = resolve_tasks(&loaded, &graph, &[]).unwrap();
905        let inputs = &resolved["app:build"];
906        // What the inputs select is declared, and kept whole.
907        assert_eq!(
908            inputs.declared.files,
909            BTreeSet::from(["app/meta.json", "nx.json", "pnpm-lock.yaml"].map(String::from))
910        );
911        // qk's own copies of those files are left out, as covered. nx.json
912        // is not one: qk keys its settings, and the input the file whole.
913        assert!(!inputs.inferred.files.contains("nx.json"));
914        assert!(inputs.inferred.settings.contains("nx.json"));
915        assert!(inputs.inferred.project.contains("app/project.json"));
916        assert_eq!(inputs.inferred.lockfile, None);
917        assert_eq!(
918            inputs.inferred.covered,
919            BTreeSet::from(["pnpm-lock.yaml"].map(String::from))
920        );
921        assert!(inputs.always_included().contains("nx.json"));
922        // Without an input naming it, a readable lockfile counts by installs.
923        let (_root, loaded, graph) = workspace(
924            json!(["{projectRoot}/meta.json"]),
925            Some(&lockfile("1.0.0", "1.0.0")),
926        );
927        let resolved = resolve_tasks(&loaded, &graph, &[]).unwrap();
928        let inputs = &resolved["app:build"];
929        assert!(!inputs.declared.files.contains("pnpm-lock.yaml"));
930        assert!(inputs.inferred.lockfile.is_some());
931        assert!(inputs.inferred.covered.is_empty());
932    }
933
934    #[test]
935    fn dependency_manifests_change_by_what_they_decide_for_dependents() {
936        let root = tempfile::tempdir().unwrap();
937        let path = "lib/package.json";
938        let inputs = |lockfile: bool| Resolved {
939            declared: Declared::default(),
940            inferred: Inferred {
941                manifests: BTreeMap::from([(path.to_owned(), Value::Null)]),
942                lockfile: lockfile.then(BTreeSet::new),
943                lockfile_readable: lockfile,
944                ..Inferred::default()
945            },
946        };
947        let changed = vec![path.to_owned()];
948        let reasons = |before: Option<&str>, after: Option<&str>, lockfile: bool| {
949            InputChanges::new(root.path(), &changed, None, false)
950                .unwrap()
951                .with_manifests(BTreeMap::from([(
952                    path.to_owned(),
953                    (before.map(String::from), after.map(String::from)),
954                )]))
955                .reasons(&inputs(lockfile))
956                .unwrap()
957        };
958        let input = [InputChange::Input { file: path.into() }];
959        let base = r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^1"}}"#;
960        assert!(
961            reasons(
962                Some(base),
963                Some(r#"{"name":"lib","version":"2.0.0","dependencies":{"a":"^1"}}"#),
964                false
965            )
966            .is_empty()
967        );
968        assert_eq!(
969            reasons(
970                Some(base),
971                Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
972                false
973            ),
974            input
975        );
976        assert!(
977            reasons(
978                Some(base),
979                Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
980                true
981            )
982            .is_empty()
983        );
984        assert_eq!(
985            reasons(Some(base), Some(r#"{"name":"other"}"#), true),
986            input
987        );
988        // Added, deleted or unreadable manifests count as changed.
989        assert_eq!(reasons(None, Some(base), true), input);
990        assert_eq!(reasons(Some(base), None, true), input);
991        assert_eq!(reasons(Some("{"), Some("{"), true), input);
992        // So does one whose revisions were not read.
993        assert_eq!(
994            InputChanges::new(root.path(), &changed, None, false)
995                .unwrap()
996                .reasons(&inputs(true))
997                .unwrap(),
998            input
999        );
1000    }
1001
1002    #[test]
1003    fn shared_declarations_do_not_execute_runtime_inputs_or_read_environment_values() {
1004        let (root, workspace, graph) = workspace(
1005            json!([
1006                {"json": "{projectRoot}/meta.json", "fields": ["version"]},
1007                {"env": "PATH"}, {"runtime": "echo evaluated > runtime-marker"},
1008                {"dependentTasksOutputFiles": "**/*.js", "transitive": true}
1009            ]),
1010            None,
1011        );
1012        let changed = vec!["runtime-marker".into(), "app/meta.json".into()];
1013        let resolved = resolve_tasks(&workspace, &graph, &changed).unwrap();
1014        let inputs = &resolved["app:build"];
1015        assert_eq!(inputs.json_inputs().collect::<Vec<_>>(), ["app/meta.json"]);
1016        assert_eq!(inputs.environment_inputs().collect::<Vec<_>>(), ["PATH"]);
1017        assert_eq!(
1018            inputs.runtime_inputs().collect::<Vec<_>>(),
1019            ["echo evaluated > runtime-marker"]
1020        );
1021        assert_eq!(inputs.declared.values["env:PATH"], Value::Null);
1022        assert_eq!(
1023            inputs.declared.values["runtime:echo evaluated > runtime-marker"],
1024            Value::Null
1025        );
1026        assert_eq!(
1027            inputs.dependency_output_inputs().collect::<Vec<_>>(),
1028            [("**/*.js", true)]
1029        );
1030        assert!(inputs.file_inputs().any(|path| path == "app/meta.json"));
1031        assert_eq!(
1032            InputChanges::new(root.path(), &changed, None, false)
1033                .unwrap()
1034                .reasons(inputs)
1035                .unwrap(),
1036            [InputChange::Input {
1037                file: "app/meta.json".into()
1038            }]
1039        );
1040        assert!(!root.path().join("runtime-marker").exists());
1041        let each = resolve_each(&workspace, &graph).unwrap();
1042        assert_eq!(
1043            each.tasks["app:build"].as_ref().unwrap().declared.values,
1044            inputs.declared.values
1045        );
1046        assert!(!root.path().join("runtime-marker").exists());
1047    }
1048
1049    fn lockfile(app: &str, other: &str) -> String {
1050        let versions = BTreeSet::from([app, other]);
1051        let packages: String = versions
1052            .iter()
1053            .map(|version| {
1054                format!("  react@{version}:\n    resolution: {{integrity: sha512-{version}}}\n")
1055            })
1056            .collect();
1057        let snapshots: String = versions
1058            .iter()
1059            .map(|version| format!("  react@{version}: {{}}\n"))
1060            .collect();
1061        format!(
1062            "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}"
1063        )
1064    }
1065
1066    #[test]
1067    fn installation_changes_preserve_selectivity_fallback_and_reason_order() {
1068        let before_text = lockfile("1.0.0", "1.0.0");
1069        let (root, workspace, graph) = workspace(
1070            json!([
1071                {"json": "{projectRoot}/meta.json"}, {"externalDependencies": ["react"]}
1072            ]),
1073            Some(&before_text),
1074        );
1075        let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
1076        let inputs = &resolved["app:build"];
1077        assert!(inputs.inferred.is_resolution_metadata("pnpm-lock.yaml"));
1078        assert!(
1079            inputs
1080                .inferred
1081                .is_resolution_metadata("pnpm-workspace.yaml")
1082        );
1083        let before = Lockfile::parse(&before_text).unwrap();
1084        let unrelated = Lockfile::parse(&lockfile("1.0.0", "2.0.0")).unwrap();
1085        let changed = vec![
1086            "pnpm-workspace.yaml".into(),
1087            "pnpm-lock.yaml".into(),
1088            "app/meta.json".into(),
1089        ];
1090        let mut changes =
1091            InputChanges::new(root.path(), &changed, Some((&before, &unrelated)), true).unwrap();
1092        // A named external package sees every importer; the task's own importer
1093        // is unchanged, so this must not report an installation change for app.
1094        assert_eq!(
1095            changes.reasons(inputs).unwrap(),
1096            [
1097                InputChange::Input {
1098                    file: "app/meta.json".into()
1099                },
1100                InputChange::WorkspaceFile,
1101                InputChange::Package {
1102                    name: "react".into()
1103                },
1104            ]
1105        );
1106        let after = Lockfile::parse(
1107            &(lockfile("2.0.0", "2.0.0") + "settings:\n  autoInstallPeers: false\n"),
1108        )
1109        .unwrap();
1110        assert_eq!(
1111            InputChanges::new(root.path(), &changed, Some((&before, &after)), true)
1112                .unwrap()
1113                .reasons(inputs)
1114                .unwrap(),
1115            [
1116                InputChange::Input {
1117                    file: "app/meta.json".into()
1118                },
1119                InputChange::WorkspaceFile,
1120                InputChange::Lockfile,
1121                InputChange::Installs {
1122                    importer: "app".into()
1123                },
1124                InputChange::Package {
1125                    name: "react".into()
1126                },
1127            ]
1128        );
1129        assert_eq!(
1130            InputChanges::new(root.path(), &changed, None, false)
1131                .unwrap()
1132                .reasons(inputs)
1133                .unwrap(),
1134            [
1135                InputChange::Input {
1136                    file: "app/meta.json".into()
1137                },
1138                InputChange::Input {
1139                    file: "pnpm-lock.yaml".into()
1140                },
1141            ]
1142        );
1143    }
1144}