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 each JSON input selects, by file, so that a change can be
37    /// judged by them.
38    pub json_fields: BTreeMap<String, 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)]
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(fields), Some((before, after))) =
456            (inputs.declared.json_fields.get(file), self.json.get(file))
457        else {
458            return false;
459        };
460        let read = |text: &Option<String>| {
461            text.as_deref()
462                .and_then(|text| hash::json_projection(file, text, fields))
463        };
464        let before = read(before);
465        before.is_some() && before == read(after)
466    }
467}
468
469/// Manifest fields that cannot change what a dependent task reads or runs: a
470/// dependency's scripts run only as its own tasks, and the rest is metadata
471/// that resolvers and bundlers do not read. A version can still add or remove
472/// a dependency through the range a dependent declares; that changes the
473/// project graph, and the dependent's key with it.
474const DEPENDENT_IGNORED_FIELDS: &[&str] = &[
475    "version",
476    "scripts",
477    "description",
478    "keywords",
479    "author",
480    "contributors",
481    "maintainers",
482    "license",
483    "homepage",
484    "repository",
485    "bugs",
486    "funding",
487    "private",
488    "publishConfig",
489];
490
491/// Dependency declarations, whose effect reaches dependents through the
492/// lockfile's record of what each importer installs.
493const DECLARATION_FIELDS: &[&str] = &[
494    "dependencies",
495    "devDependencies",
496    "peerDependencies",
497    "peerDependenciesMeta",
498    "optionalDependencies",
499    "dependenciesMeta",
500];
501
502/// A dependency's `package.json` as its dependents' keys read it: without
503/// fields that cannot change what they read or run, and, when the lockfile
504/// records the installs, without the declarations it resolved. `None` when it
505/// cannot be read as a JSON object.
506pub fn manifest_for_dependents(text: &str, lockfile: bool) -> Option<Value> {
507    let mut value: Value = serde_json::from_str(text).ok()?;
508    let object = value.as_object_mut()?;
509    object.retain(|key, _| {
510        !DEPENDENT_IGNORED_FIELDS.contains(&key.as_str())
511            && !(lockfile && DECLARATION_FIELDS.contains(&key.as_str()))
512    });
513    Some(value)
514}
515
516/// `pnpm-workspace.yaml` without the keys that configure resolution, or `None`
517/// when it cannot be read as YAML.
518pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
519    let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
520    without_resolution(&text)
521}
522
523/// A `pnpm-workspace.yaml` text without its resolution keys.
524pub fn without_resolution(text: &str) -> Option<Value> {
525    let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
526    if let Some(object) = value.as_object_mut() {
527        object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
528    }
529    Some(value)
530}
531
532/// Paths a selected symlink reads, including a target that has been deleted.
533fn symlink_targets(
534    root: &Path,
535    path: &str,
536    directory_links: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
537) -> Result<Vec<String>> {
538    let mut pending = vec![root.join(path)];
539    let mut visited = BTreeSet::new();
540    let mut targets = BTreeSet::new();
541    while let Some(absolute) = pending.pop() {
542        if !visited.insert(absolute.clone()) {
543            continue;
544        }
545        let metadata = match std::fs::symlink_metadata(&absolute) {
546            Ok(metadata) => metadata,
547            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
548            Err(error) => return Err(error.into()),
549        };
550        if !metadata.file_type().is_symlink() {
551            continue;
552        }
553        let target = absolute
554            .parent()
555            .expect("input has a parent")
556            .join(std::fs::read_link(&absolute)?);
557        let mut normalized = PathBuf::new();
558        for component in target.components() {
559            match component {
560                Component::CurDir => {}
561                Component::ParentDir => {
562                    normalized.pop();
563                }
564                component => normalized.push(component.as_os_str()),
565            }
566        }
567        let resolved = target.canonicalize().ok().or_else(|| {
568            Some(
569                target
570                    .parent()?
571                    .canonicalize()
572                    .ok()?
573                    .join(target.file_name()?),
574            )
575        });
576        if normalized.starts_with(root) {
577            pending.push(normalized.clone());
578        }
579        if let Some(resolved) = &resolved
580            && resolved.starts_with(root)
581            && resolved.is_dir()
582        {
583            if !directory_links.contains_key(resolved) {
584                let mut links = Vec::new();
585                for entry in walkdir::WalkDir::new(resolved).follow_links(false) {
586                    let entry = entry?;
587                    if entry.file_type().is_symlink() {
588                        links.push(entry.into_path());
589                    }
590                }
591                directory_links.insert(resolved.clone(), links);
592            }
593            pending.extend(directory_links[resolved].iter().cloned());
594        }
595        for target in std::iter::once(normalized).chain(resolved) {
596            if let Ok(relative) = target.strip_prefix(root)
597                && let Some(relative) = relative.to_str()
598            {
599                targets.insert(relative.replace('\\', "/"));
600            }
601        }
602    }
603    Ok(targets.into_iter().collect())
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use qk_taskgraph::Request;
610    use serde_json::json;
611
612    fn workspace(
613        inputs: Value,
614        lockfile: Option<&str>,
615    ) -> (tempfile::TempDir, Workspace, TaskGraph) {
616        let root = tempfile::tempdir().unwrap();
617        std::fs::create_dir(root.path().join("app")).unwrap();
618        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
619        std::fs::write(
620            root.path().join("app/project.json"),
621            json!({
622                "name": "app", "targets": {"build": {"command": "echo build", "inputs": inputs}}
623            })
624            .to_string(),
625        )
626        .unwrap();
627        std::fs::write(root.path().join("app/meta.json"), r#"{"version":1}"#).unwrap();
628        if let Some(lockfile) = lockfile {
629            std::fs::write(root.path().join("pnpm-lock.yaml"), lockfile).unwrap();
630            std::fs::write(
631                root.path().join("pnpm-workspace.yaml"),
632                "packages:\n  - app\n",
633            )
634            .unwrap();
635        }
636        let workspace = Workspace::load(root.path()).unwrap();
637        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
638        (root, workspace, graph)
639    }
640
641    /// The files `app:build` declares with `inputs`, in a workspace where
642    /// `app` depends on `lib` and both hold a few files.
643    fn declared(inputs: Value) -> Result<BTreeSet<String>> {
644        declared_with(inputs, &[])
645    }
646
647    fn declared_with(inputs: Value, extra: &[&str]) -> Result<BTreeSet<String>> {
648        let root = tempfile::tempdir().unwrap();
649        for file in extra {
650            let path = root.path().join(file);
651            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
652            std::fs::write(path, "").unwrap();
653        }
654        std::fs::write(root.path().join(".gitignore"), "dist/\n").unwrap();
655        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
656        for (path, text) in [
657            (
658                "app/project.json",
659                json!({"name": "app", "implicitDependencies": ["lib"],
660                    "namedInputs": {"production": ["{projectRoot}/src/**/*"]},
661                    "targets": {"build": {"command": "true", "inputs": inputs}}})
662                .to_string(),
663            ),
664            ("lib/project.json", json!({"name": "lib"}).to_string()),
665            ("app/src/main.ts", String::new()),
666            ("app/src/app/(group)/page.ts", String::new()),
667            ("app/src/app/group/page.ts", String::new()),
668            ("app/app/notes.md", String::new()),
669            ("app/workspaceRoot/x.ts", String::new()),
670            ("lib/src/index.ts", String::new()),
671            ("lib/lib.ts", String::new()),
672            ("tools/gen.ts", String::new()),
673        ] {
674            let path = root.path().join(path);
675            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
676            std::fs::write(path, text).unwrap();
677        }
678        let workspace = Workspace::load(root.path()).unwrap();
679        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
680        let mut resolved = resolve_tasks(&workspace, &graph, &[])?;
681        Ok(resolved.remove("app:build").unwrap().declared.files)
682    }
683
684    fn files(paths: &[&str]) -> BTreeSet<String> {
685        paths.iter().map(|path| (*path).to_owned()).collect()
686    }
687
688    #[test]
689    fn dependency_filesets_are_written_with_a_caret() {
690        assert_eq!(
691            declared(json!([
692                "^{projectRoot}/src/**/*",
693                "^{workspaceRoot}/tools/**/*"
694            ]))
695            .unwrap(),
696            files(&["lib/src/index.ts", "tools/gen.ts"])
697        );
698        // Only the string spelling takes the caret.
699        assert!(declared(json!([{"fileset": "^{projectRoot}/src/**/*"}])).is_err());
700    }
701
702    #[test]
703    fn filesets_read_the_project_name_and_workspace_root_only_at_the_start() {
704        assert_eq!(
705            declared(json!(["{workspaceRoot}/{projectName}/src/main.ts"])).unwrap(),
706            files(&["app/src/main.ts"])
707        );
708        // Elsewhere `{workspaceRoot}` is glob text: a group of one name.
709        assert_eq!(
710            declared(json!(["{projectRoot}/{workspaceRoot}/x.ts"])).unwrap(),
711            files(&["app/workspaceRoot/x.ts"])
712        );
713        // A string that is no named input must be a fileset from a root.
714        assert!(declared(json!(["src/**/*"])).is_err());
715        assert_eq!(declared(json!(["production"])).unwrap().len(), 3);
716    }
717
718    #[test]
719    fn a_fileset_naming_a_directory_selects_everything_under_it() {
720        let src = files(&[
721            "app/src/main.ts",
722            "app/src/app/(group)/page.ts",
723            "app/src/app/group/page.ts",
724        ]);
725        for fileset in [
726            "{projectRoot}/src",
727            "{projectRoot}/src/",
728            "{projectRoot}/src/**/*",
729        ] {
730            assert_eq!(declared(json!([fileset])).unwrap(), src, "{fileset}");
731        }
732        assert_eq!(
733            declared(json!(["{projectRoot}/**/*", "!{projectRoot}/src"])).unwrap(),
734            files(&[
735                "app/app/notes.md",
736                "app/project.json",
737                "app/workspaceRoot/x.ts"
738            ])
739        );
740    }
741
742    #[test]
743    fn a_backslash_escapes_a_fileset_character() {
744        assert_eq!(
745            declared(json!([r"{projectRoot}/src/app/\(group\)/**/*"])).unwrap(),
746            files(&["app/src/app/(group)/page.ts"])
747        );
748        assert_eq!(
749            declared(json!([r"{projectRoot}/src/app/\(group\)"])).unwrap(),
750            files(&["app/src/app/(group)/page.ts"])
751        );
752    }
753
754    #[test]
755    fn json_inputs_can_select_several_files() {
756        let root = tempfile::tempdir().unwrap();
757        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
758        for (path, text) in [
759            (
760                "app/project.json",
761                json!({"name": "app", "targets": {"build": {"command": "true",
762                    "inputs": [{"json": "{projectRoot}/config/*.json", "fields": ["a"]},
763                        {"json": "{workspaceRoot}/shared/**/*.json"}]}}})
764                .to_string(),
765            ),
766            ("app/config/one.json", r#"{"a": 1, "b": 2}"#.to_owned()),
767            ("app/config/two.json", r#"{"a": 3}"#.to_owned()),
768            ("app/config/notes.txt", String::new()),
769            (
770                "app/config/nested/project.json",
771                json!({"name": "nested"}).to_string(),
772            ),
773            ("app/config/nested/x.json", "{}".to_owned()),
774            ("shared/deep/s.json", r#"{"s": true}"#.to_owned()),
775        ] {
776            let path = root.path().join(path);
777            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
778            std::fs::write(path, text).unwrap();
779        }
780        let workspace = Workspace::load(root.path()).unwrap();
781        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
782        let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
783        let declared = &resolved["app:build"].declared;
784        assert_eq!(
785            declared.values,
786            BTreeMap::from([
787                ("json:app/config/one.json".to_owned(), json!({"a": 1})),
788                ("json:app/config/two.json".to_owned(), json!({"a": 3})),
789                ("json:shared/deep/s.json".to_owned(), json!({"s": true})),
790            ])
791        );
792    }
793
794    #[test]
795    fn include_ignored_groups_filter_their_own_positives() {
796        let dist = [
797            "lib/dist/index.js",
798            "lib/dist/index.js.map",
799            "app/dist/main.js",
800        ];
801        let dependencies = |fileset: &str| json!({"fileset": fileset, "includeIgnored": true, "dependencies": true});
802        assert_eq!(
803            declared_with(
804                json!([
805                    dependencies("{projectRoot}/dist/**"),
806                    dependencies("!{projectRoot}/dist/**/*.map"),
807                    {"fileset": "{projectRoot}/dist", "includeIgnored": true},
808                ]),
809                &dist
810            )
811            .unwrap(),
812            files(&["app/dist/main.js", "lib/dist/index.js"])
813        );
814        // A negation filters only the group it is resolved with.
815        let error = declared_with(
816            json!([
817                dependencies("{projectRoot}/dist/**"),
818                {"fileset": "!{projectRoot}/dist/**/*.map", "includeIgnored": true},
819            ]),
820            &dist,
821        )
822        .unwrap_err();
823        assert!(
824            format!("{error:#}").contains("no positive includeIgnored fileset"),
825            "{error:#}"
826        );
827        // Without the flag, the ignored directory selects nothing.
828        assert!(
829            declared_with(json!(["{projectRoot}/dist/**"]), &dist)
830                .unwrap()
831                .is_empty()
832        );
833    }
834
835    #[test]
836    fn always_inputs_must_select_a_project() {
837        let error =
838            declared(json!([{"input": "default", "projects": ["lib", "missing"], "always": true}]))
839                .unwrap_err();
840        assert!(
841            format!("{error:#}").contains("\"missing\", which matches no project"),
842            "{error:#}"
843        );
844        assert!(declared(json!([{"input": "default", "projects": ["missing"]}])).is_ok());
845    }
846
847    #[test]
848    fn external_dependencies_cannot_name_a_project() {
849        let error = declared(json!([{"externalDependencies": ["lib"]}])).unwrap_err();
850        assert!(
851            format!("{error:#}").contains("already a dependency"),
852            "{error:#}"
853        );
854    }
855
856    #[test]
857    fn declared_inputs_cover_the_files_qk_would_add() {
858        let (_root, loaded, graph) = workspace(
859            json!([
860                "{projectRoot}/meta.json",
861                "{workspaceRoot}/nx.json",
862                "{workspaceRoot}/pnpm-lock.yaml"
863            ]),
864            Some(&lockfile("1.0.0", "1.0.0")),
865        );
866        let resolved = resolve_tasks(&loaded, &graph, &[]).unwrap();
867        let inputs = &resolved["app:build"];
868        // What the inputs select is declared, and kept whole.
869        assert_eq!(
870            inputs.declared.files,
871            BTreeSet::from(["app/meta.json", "nx.json", "pnpm-lock.yaml"].map(String::from))
872        );
873        // qk's own copies of those files are left out, as covered.
874        assert!(!inputs.inferred.files.contains("nx.json"));
875        assert!(inputs.inferred.files.contains("app/project.json"));
876        assert_eq!(inputs.inferred.lockfile, None);
877        assert_eq!(
878            inputs.inferred.covered,
879            BTreeSet::from(["nx.json", "pnpm-lock.yaml"].map(String::from))
880        );
881        assert!(inputs.always_included().contains("nx.json"));
882        // Without an input naming it, a readable lockfile counts by installs.
883        let (_root, loaded, graph) = workspace(
884            json!(["{projectRoot}/meta.json"]),
885            Some(&lockfile("1.0.0", "1.0.0")),
886        );
887        let resolved = resolve_tasks(&loaded, &graph, &[]).unwrap();
888        let inputs = &resolved["app:build"];
889        assert!(!inputs.declared.files.contains("pnpm-lock.yaml"));
890        assert!(inputs.inferred.lockfile.is_some());
891        assert!(inputs.inferred.covered.is_empty());
892    }
893
894    #[test]
895    fn dependency_manifests_change_by_what_they_decide_for_dependents() {
896        let root = tempfile::tempdir().unwrap();
897        let path = "lib/package.json";
898        let inputs = |lockfile: bool| Resolved {
899            declared: Declared::default(),
900            inferred: Inferred {
901                manifests: BTreeMap::from([(path.to_owned(), Value::Null)]),
902                lockfile: lockfile.then(BTreeSet::new),
903                lockfile_readable: lockfile,
904                ..Inferred::default()
905            },
906        };
907        let changed = vec![path.to_owned()];
908        let reasons = |before: Option<&str>, after: Option<&str>, lockfile: bool| {
909            InputChanges::new(root.path(), &changed, None, false)
910                .unwrap()
911                .with_manifests(BTreeMap::from([(
912                    path.to_owned(),
913                    (before.map(String::from), after.map(String::from)),
914                )]))
915                .reasons(&inputs(lockfile))
916                .unwrap()
917        };
918        let input = [InputChange::Input { file: path.into() }];
919        let base = r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^1"}}"#;
920        assert!(
921            reasons(
922                Some(base),
923                Some(r#"{"name":"lib","version":"2.0.0","dependencies":{"a":"^1"}}"#),
924                false
925            )
926            .is_empty()
927        );
928        assert_eq!(
929            reasons(
930                Some(base),
931                Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
932                false
933            ),
934            input
935        );
936        assert!(
937            reasons(
938                Some(base),
939                Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
940                true
941            )
942            .is_empty()
943        );
944        assert_eq!(
945            reasons(Some(base), Some(r#"{"name":"other"}"#), true),
946            input
947        );
948        // Added, deleted or unreadable manifests count as changed.
949        assert_eq!(reasons(None, Some(base), true), input);
950        assert_eq!(reasons(Some(base), None, true), input);
951        assert_eq!(reasons(Some("{"), Some("{"), true), input);
952        // So does one whose revisions were not read.
953        assert_eq!(
954            InputChanges::new(root.path(), &changed, None, false)
955                .unwrap()
956                .reasons(&inputs(true))
957                .unwrap(),
958            input
959        );
960    }
961
962    #[test]
963    fn shared_declarations_do_not_execute_runtime_inputs_or_read_environment_values() {
964        let (root, workspace, graph) = workspace(
965            json!([
966                {"json": "{projectRoot}/meta.json", "fields": ["version"]},
967                {"env": "PATH"}, {"runtime": "echo evaluated > runtime-marker"},
968                {"dependentTasksOutputFiles": "**/*.js", "transitive": true}
969            ]),
970            None,
971        );
972        let changed = vec!["runtime-marker".into(), "app/meta.json".into()];
973        let resolved = resolve_tasks(&workspace, &graph, &changed).unwrap();
974        let inputs = &resolved["app:build"];
975        assert_eq!(inputs.json_inputs().collect::<Vec<_>>(), ["app/meta.json"]);
976        assert_eq!(inputs.environment_inputs().collect::<Vec<_>>(), ["PATH"]);
977        assert_eq!(
978            inputs.runtime_inputs().collect::<Vec<_>>(),
979            ["echo evaluated > runtime-marker"]
980        );
981        assert_eq!(inputs.declared.values["env:PATH"], Value::Null);
982        assert_eq!(
983            inputs.declared.values["runtime:echo evaluated > runtime-marker"],
984            Value::Null
985        );
986        assert_eq!(
987            inputs.dependency_output_inputs().collect::<Vec<_>>(),
988            [("**/*.js", true)]
989        );
990        assert!(inputs.file_inputs().any(|path| path == "app/meta.json"));
991        assert_eq!(
992            InputChanges::new(root.path(), &changed, None, false)
993                .unwrap()
994                .reasons(inputs)
995                .unwrap(),
996            [InputChange::Input {
997                file: "app/meta.json".into()
998            }]
999        );
1000        assert!(!root.path().join("runtime-marker").exists());
1001        let each = resolve_each(&workspace, &graph).unwrap();
1002        assert_eq!(
1003            each.tasks["app:build"].as_ref().unwrap().declared.values,
1004            inputs.declared.values
1005        );
1006        assert!(!root.path().join("runtime-marker").exists());
1007    }
1008
1009    fn lockfile(app: &str, other: &str) -> String {
1010        let versions = BTreeSet::from([app, other]);
1011        let packages: String = versions
1012            .iter()
1013            .map(|version| {
1014                format!("  react@{version}:\n    resolution: {{integrity: sha512-{version}}}\n")
1015            })
1016            .collect();
1017        let snapshots: String = versions
1018            .iter()
1019            .map(|version| format!("  react@{version}: {{}}\n"))
1020            .collect();
1021        format!(
1022            "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}"
1023        )
1024    }
1025
1026    #[test]
1027    fn installation_changes_preserve_selectivity_fallback_and_reason_order() {
1028        let before_text = lockfile("1.0.0", "1.0.0");
1029        let (root, workspace, graph) = workspace(
1030            json!([
1031                {"json": "{projectRoot}/meta.json"}, {"externalDependencies": ["react"]}
1032            ]),
1033            Some(&before_text),
1034        );
1035        let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
1036        let inputs = &resolved["app:build"];
1037        assert!(inputs.inferred.is_resolution_metadata("pnpm-lock.yaml"));
1038        assert!(
1039            inputs
1040                .inferred
1041                .is_resolution_metadata("pnpm-workspace.yaml")
1042        );
1043        let before = Lockfile::parse(&before_text).unwrap();
1044        let unrelated = Lockfile::parse(&lockfile("1.0.0", "2.0.0")).unwrap();
1045        let changed = vec![
1046            "pnpm-workspace.yaml".into(),
1047            "pnpm-lock.yaml".into(),
1048            "app/meta.json".into(),
1049        ];
1050        let mut changes =
1051            InputChanges::new(root.path(), &changed, Some((&before, &unrelated)), true).unwrap();
1052        // A named external package sees every importer; the task's own importer
1053        // is unchanged, so this must not report an installation change for app.
1054        assert_eq!(
1055            changes.reasons(inputs).unwrap(),
1056            [
1057                InputChange::Input {
1058                    file: "app/meta.json".into()
1059                },
1060                InputChange::WorkspaceFile,
1061                InputChange::Package {
1062                    name: "react".into()
1063                },
1064            ]
1065        );
1066        let after = Lockfile::parse(
1067            &(lockfile("2.0.0", "2.0.0") + "settings:\n  autoInstallPeers: false\n"),
1068        )
1069        .unwrap();
1070        assert_eq!(
1071            InputChanges::new(root.path(), &changed, Some((&before, &after)), true)
1072                .unwrap()
1073                .reasons(inputs)
1074                .unwrap(),
1075            [
1076                InputChange::Input {
1077                    file: "app/meta.json".into()
1078                },
1079                InputChange::WorkspaceFile,
1080                InputChange::Lockfile,
1081                InputChange::Installs {
1082                    importer: "app".into()
1083                },
1084                InputChange::Package {
1085                    name: "react".into()
1086                },
1087            ]
1088        );
1089        assert_eq!(
1090            InputChanges::new(root.path(), &changed, None, false)
1091                .unwrap()
1092                .reasons(inputs)
1093                .unwrap(),
1094            [
1095                InputChange::Input {
1096                    file: "app/meta.json".into()
1097                },
1098                InputChange::Input {
1099                    file: "pnpm-lock.yaml".into()
1100                },
1101            ]
1102        );
1103    }
1104}