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