1use 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
17pub struct Resolved {
20 pub declared: Declared,
21 pub inferred: Inferred,
22}
23
24#[derive(Default)]
27pub struct Declared {
28 pub files: BTreeSet<String>,
30 pub ignored: BTreeSet<String>,
33 pub values: BTreeMap<String, Value>,
36 pub json_fields: BTreeMap<String, JsonFields>,
39 pub external: BTreeSet<String>,
41 pub external_declared: bool,
44 pub projects: BTreeSet<String>,
49 pub producers: BTreeSet<String>,
53}
54
55#[derive(Default)]
59pub struct Inferred {
60 pub files: BTreeSet<String>,
63 pub workspace_file: bool,
66 pub lockfile: Option<BTreeSet<String>>,
68 pub lockfile_readable: bool,
71 pub manifests: BTreeMap<String, Value>,
74 pub tsconfig: Option<String>,
78 pub covered: BTreeSet<String>,
80}
81
82#[derive(Clone, Debug, Default)]
84pub struct JsonFields {
85 pub fields: Option<Vec<String>>,
87 pub exclude: Vec<String>,
89}
90
91impl Inferred {
92 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 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
111pub const ROOT_TSCONFIG_FILES: [&str; 2] = ["tsconfig.base.json", "tsconfig.json"];
113
114pub 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
131pub 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 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 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 pub fn is_whole_file(&self, path: &str) -> bool {
188 self.declared.files.contains(path) || self.inferred.files.contains(path)
189 }
190
191 pub fn file_inputs(&self) -> impl Iterator<Item = &str> {
194 self.whole_files().chain(self.json_inputs())
195 }
196
197 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 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
242pub 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
264pub 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#[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
300pub 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 manifests: BTreeMap<String, (Option<String>, Option<String>)>,
311 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 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 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 pub fn with_json(mut self, json: BTreeMap<String, (Option<String>, Option<String>)>) -> Self {
355 self.json = json;
356 self
357 }
358
359 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 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 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 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
469const 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
491const DECLARATION_FIELDS: &[&str] = &[
494 "dependencies",
495 "devDependencies",
496 "peerDependencies",
497 "peerDependenciesMeta",
498 "optionalDependencies",
499 "dependenciesMeta",
500];
501
502pub 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
516pub 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
523pub 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
532fn 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 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 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 assert_eq!(
710 declared(json!(["{projectRoot}/{workspaceRoot}/x.ts"])).unwrap(),
711 files(&["app/workspaceRoot/x.ts"])
712 );
713 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 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 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 assert_eq!(
870 inputs.declared.files,
871 BTreeSet::from(["app/meta.json", "nx.json", "pnpm-lock.yaml"].map(String::from))
872 );
873 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 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 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 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 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}