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