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 {
19 pub files: BTreeSet<String>,
21 pub mandatory: BTreeSet<String>,
23 pub values: BTreeMap<String, Value>,
26 pub lockfile: Option<(BTreeSet<String>, BTreeSet<String>)>,
29 pub workspace_file: bool,
32 pub external: BTreeSet<String>,
34}
35
36impl Resolved {
37 pub fn file_inputs(&self) -> impl Iterator<Item = &str> {
39 self.files
40 .iter()
41 .map(String::as_str)
42 .chain(self.json_inputs())
43 }
44
45 pub fn json_inputs(&self) -> impl Iterator<Item = &str> {
46 self.value_names("json:")
47 }
48
49 pub fn is_json_input(&self, path: &str) -> bool {
50 self.values.contains_key(&format!("json:{path}"))
51 }
52
53 pub fn environment_inputs(&self) -> impl Iterator<Item = &str> {
54 self.value_names("env:")
55 }
56
57 pub fn runtime_inputs(&self) -> impl Iterator<Item = &str> {
58 self.value_names("runtime:")
59 }
60
61 pub fn dependency_output_inputs(&self) -> impl Iterator<Item = (&str, bool)> {
63 self.values.iter().filter_map(|(name, transitive)| {
64 name.strip_prefix("dependentTasksOutputFiles:")
65 .map(|pattern| (pattern, transitive == &Value::Bool(true)))
66 })
67 }
68
69 pub fn is_resolution_metadata(&self, path: &str) -> bool {
71 (path == "pnpm-lock.yaml" && self.lockfile.is_some())
72 || (path == "pnpm-workspace.yaml" && self.workspace_file)
73 }
74
75 fn value_names<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a str> {
76 self.values
77 .keys()
78 .filter_map(move |name| name.strip_prefix(prefix))
79 }
80}
81
82pub fn resolve_tasks(
86 workspace: &Workspace,
87 graph: &TaskGraph,
88 extra: &[String],
89) -> Result<BTreeMap<String, Resolved>> {
90 let cache = paths::cache_location(workspace);
91 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
92 let cancelled = AtomicBool::new(false);
93 graph
94 .tasks
95 .iter()
96 .map(|(id, task)| {
97 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
98 .with_context(|| format!("cannot resolve the inputs of {id}"))?;
99 Ok((id.clone(), resolved))
100 })
101 .collect()
102}
103
104pub struct Resolution {
107 pub candidates: std::collections::BTreeSet<String>,
108 pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
109}
110
111pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
112 let cache = paths::cache_location(workspace);
113 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
114 let cancelled = AtomicBool::new(false);
115 let tasks = graph
116 .tasks
117 .iter()
118 .map(|(id, task)| {
119 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
120 .map_err(|error| format!("{error:#}"));
121 (id.clone(), resolved)
122 })
123 .collect();
124 Ok(Resolution {
125 candidates: snapshot.files.clone(),
126 tasks,
127 })
128}
129
130#[derive(Debug, PartialEq, Eq)]
132pub enum InputChange {
133 Input { file: String },
134 Installs { importer: String },
135 Package { name: String },
136 Lockfile,
137 WorkspaceFile,
138}
139
140pub struct InputChanges<'a> {
144 root: PathBuf,
145 files: &'a [String],
146 changed: BTreeSet<&'a str>,
147 lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
148 workspace_file_changed: bool,
149 links: BTreeMap<String, Vec<String>>,
150 directory_links: BTreeMap<PathBuf, Vec<PathBuf>>,
151}
152
153impl<'a> InputChanges<'a> {
154 pub fn new(
158 root: &Path,
159 files: &'a [String],
160 lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
161 workspace_file_changed: bool,
162 ) -> Result<Self> {
163 Ok(Self {
164 root: root.canonicalize()?,
165 files,
166 changed: files.iter().map(String::as_str).collect(),
167 lockfiles,
168 workspace_file_changed,
169 links: BTreeMap::new(),
170 directory_links: BTreeMap::new(),
171 })
172 }
173
174 pub fn reasons(&mut self, inputs: &Resolved) -> Result<Vec<InputChange>> {
177 let mut reasons = Vec::new();
178 let mut touched = BTreeSet::new();
179 for file in inputs.file_inputs() {
180 if self.changed.contains(file) {
181 touched.insert(file);
182 continue;
183 }
184 if !self.links.contains_key(file) {
185 self.links.insert(
186 file.to_owned(),
187 symlink_targets(&self.root, file, &mut self.directory_links)?,
188 );
189 }
190 for target in &self.links[file] {
191 touched.extend(
192 self.files
193 .iter()
194 .filter(|changed| {
195 target.is_empty()
196 || **changed == *target
197 || changed
198 .strip_prefix(target)
199 .is_some_and(|suffix| suffix.starts_with('/'))
200 })
201 .map(String::as_str),
202 );
203 }
204 }
205 for file in touched {
206 reasons.push(InputChange::Input {
207 file: file.to_owned(),
208 });
209 }
210 if inputs.workspace_file && self.workspace_file_changed {
211 reasons.push(InputChange::WorkspaceFile);
212 }
213 match (&inputs.lockfile, &self.lockfiles) {
214 (Some((importers, external)), Some((before, after))) => {
215 if before.global() != after.global() {
216 reasons.push(InputChange::Lockfile);
217 }
218 for importer in importers {
219 if before.installed(importer) != after.installed(importer) {
220 reasons.push(InputChange::Installs {
221 importer: importer.clone(),
222 });
223 }
224 }
225 for name in external {
226 if before.package(name) != after.package(name) {
227 reasons.push(InputChange::Package { name: name.clone() });
228 }
229 }
230 }
231 (Some(_), None) if self.changed.contains("pnpm-lock.yaml") => {
232 reasons.push(InputChange::Input {
233 file: "pnpm-lock.yaml".into(),
234 });
235 }
236 _ => {}
237 }
238 Ok(reasons)
239 }
240}
241
242pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
245 let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
246 without_resolution(&text)
247}
248
249pub fn without_resolution(text: &str) -> Option<Value> {
251 let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
252 if let Some(object) = value.as_object_mut() {
253 object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
254 }
255 Some(value)
256}
257
258fn symlink_targets(
260 root: &Path,
261 path: &str,
262 directory_links: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
263) -> Result<Vec<String>> {
264 let mut pending = vec![root.join(path)];
265 let mut visited = BTreeSet::new();
266 let mut targets = BTreeSet::new();
267 while let Some(absolute) = pending.pop() {
268 if !visited.insert(absolute.clone()) {
269 continue;
270 }
271 let metadata = match std::fs::symlink_metadata(&absolute) {
272 Ok(metadata) => metadata,
273 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
274 Err(error) => return Err(error.into()),
275 };
276 if !metadata.file_type().is_symlink() {
277 continue;
278 }
279 let target = absolute
280 .parent()
281 .expect("input has a parent")
282 .join(std::fs::read_link(&absolute)?);
283 let mut normalized = PathBuf::new();
284 for component in target.components() {
285 match component {
286 Component::CurDir => {}
287 Component::ParentDir => {
288 normalized.pop();
289 }
290 component => normalized.push(component.as_os_str()),
291 }
292 }
293 let resolved = target.canonicalize().ok().or_else(|| {
294 Some(
295 target
296 .parent()?
297 .canonicalize()
298 .ok()?
299 .join(target.file_name()?),
300 )
301 });
302 if normalized.starts_with(root) {
303 pending.push(normalized.clone());
304 }
305 if let Some(resolved) = &resolved
306 && resolved.starts_with(root)
307 && resolved.is_dir()
308 {
309 if !directory_links.contains_key(resolved) {
310 let mut links = Vec::new();
311 for entry in walkdir::WalkDir::new(resolved).follow_links(false) {
312 let entry = entry?;
313 if entry.file_type().is_symlink() {
314 links.push(entry.into_path());
315 }
316 }
317 directory_links.insert(resolved.clone(), links);
318 }
319 pending.extend(directory_links[resolved].iter().cloned());
320 }
321 for target in std::iter::once(normalized).chain(resolved) {
322 if let Ok(relative) = target.strip_prefix(root)
323 && let Some(relative) = relative.to_str()
324 {
325 targets.insert(relative.replace('\\', "/"));
326 }
327 }
328 }
329 Ok(targets.into_iter().collect())
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use qk_taskgraph::Request;
336 use serde_json::json;
337
338 fn workspace(
339 inputs: Value,
340 lockfile: Option<&str>,
341 ) -> (tempfile::TempDir, Workspace, TaskGraph) {
342 let root = tempfile::tempdir().unwrap();
343 std::fs::create_dir(root.path().join("app")).unwrap();
344 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
345 std::fs::write(
346 root.path().join("app/project.json"),
347 json!({
348 "name": "app", "targets": {"build": {"command": "echo build", "inputs": inputs}}
349 })
350 .to_string(),
351 )
352 .unwrap();
353 std::fs::write(root.path().join("app/meta.json"), r#"{"version":1}"#).unwrap();
354 if let Some(lockfile) = lockfile {
355 std::fs::write(root.path().join("pnpm-lock.yaml"), lockfile).unwrap();
356 std::fs::write(
357 root.path().join("pnpm-workspace.yaml"),
358 "packages:\n - app\n",
359 )
360 .unwrap();
361 }
362 let workspace = Workspace::load(root.path()).unwrap();
363 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
364 (root, workspace, graph)
365 }
366
367 #[test]
368 fn shared_declarations_do_not_execute_runtime_inputs_or_read_environment_values() {
369 let (root, workspace, graph) = workspace(
370 json!([
371 {"json": "{projectRoot}/meta.json", "fields": ["version"]},
372 {"env": "PATH"}, {"runtime": "echo evaluated > runtime-marker"},
373 {"dependentTasksOutputFiles": "**/*.js", "transitive": true}
374 ]),
375 None,
376 );
377 let changed = vec!["runtime-marker".into(), "app/meta.json".into()];
378 let resolved = resolve_tasks(&workspace, &graph, &changed).unwrap();
379 let inputs = &resolved["app:build"];
380 assert_eq!(inputs.json_inputs().collect::<Vec<_>>(), ["app/meta.json"]);
381 assert_eq!(inputs.environment_inputs().collect::<Vec<_>>(), ["PATH"]);
382 assert_eq!(
383 inputs.runtime_inputs().collect::<Vec<_>>(),
384 ["echo evaluated > runtime-marker"]
385 );
386 assert_eq!(inputs.values["env:PATH"], Value::Null);
387 assert_eq!(
388 inputs.values["runtime:echo evaluated > runtime-marker"],
389 Value::Null
390 );
391 assert_eq!(
392 inputs.dependency_output_inputs().collect::<Vec<_>>(),
393 [("**/*.js", true)]
394 );
395 assert!(inputs.file_inputs().any(|path| path == "app/meta.json"));
396 assert_eq!(
397 InputChanges::new(root.path(), &changed, None, false)
398 .unwrap()
399 .reasons(inputs)
400 .unwrap(),
401 [InputChange::Input {
402 file: "app/meta.json".into()
403 }]
404 );
405 assert!(!root.path().join("runtime-marker").exists());
406 let each = resolve_each(&workspace, &graph).unwrap();
407 assert_eq!(
408 each.tasks["app:build"].as_ref().unwrap().values,
409 inputs.values
410 );
411 assert!(!root.path().join("runtime-marker").exists());
412 }
413
414 fn lockfile(app: &str, other: &str) -> String {
415 let versions = BTreeSet::from([app, other]);
416 let packages: String = versions
417 .iter()
418 .map(|version| {
419 format!(" react@{version}:\n resolution: {{integrity: sha512-{version}}}\n")
420 })
421 .collect();
422 let snapshots: String = versions
423 .iter()
424 .map(|version| format!(" react@{version}: {{}}\n"))
425 .collect();
426 format!(
427 "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}"
428 )
429 }
430
431 #[test]
432 fn installation_changes_preserve_selectivity_fallback_and_reason_order() {
433 let before_text = lockfile("1.0.0", "1.0.0");
434 let (root, workspace, graph) = workspace(
435 json!([
436 {"json": "{projectRoot}/meta.json"}, {"externalDependencies": ["react"]}
437 ]),
438 Some(&before_text),
439 );
440 let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
441 let inputs = &resolved["app:build"];
442 assert!(inputs.is_resolution_metadata("pnpm-lock.yaml"));
443 assert!(inputs.is_resolution_metadata("pnpm-workspace.yaml"));
444 let before = Lockfile::parse(&before_text).unwrap();
445 let unrelated = Lockfile::parse(&lockfile("1.0.0", "2.0.0")).unwrap();
446 let changed = vec![
447 "pnpm-workspace.yaml".into(),
448 "pnpm-lock.yaml".into(),
449 "app/meta.json".into(),
450 ];
451 let mut changes =
452 InputChanges::new(root.path(), &changed, Some((&before, &unrelated)), true).unwrap();
453 assert_eq!(
456 changes.reasons(inputs).unwrap(),
457 [
458 InputChange::Input {
459 file: "app/meta.json".into()
460 },
461 InputChange::WorkspaceFile,
462 InputChange::Package {
463 name: "react".into()
464 },
465 ]
466 );
467 let after = Lockfile::parse(
468 &(lockfile("2.0.0", "2.0.0") + "settings:\n autoInstallPeers: false\n"),
469 )
470 .unwrap();
471 assert_eq!(
472 InputChanges::new(root.path(), &changed, Some((&before, &after)), true)
473 .unwrap()
474 .reasons(inputs)
475 .unwrap(),
476 [
477 InputChange::Input {
478 file: "app/meta.json".into()
479 },
480 InputChange::WorkspaceFile,
481 InputChange::Lockfile,
482 InputChange::Installs {
483 importer: "app".into()
484 },
485 InputChange::Package {
486 name: "react".into()
487 },
488 ]
489 );
490 assert_eq!(
491 InputChanges::new(root.path(), &changed, None, false)
492 .unwrap()
493 .reasons(inputs)
494 .unwrap(),
495 [
496 InputChange::Input {
497 file: "app/meta.json".into()
498 },
499 InputChange::Input {
500 file: "pnpm-lock.yaml".into()
501 },
502 ]
503 );
504 }
505}