Skip to main content

qk_cache/
ultracache.rs

1//! `qk:ultracache`: key a task by what it read when it last ran, rather
2//! than by its declared file inputs.
3//!
4//! A run with `--record` traces each opted-in task it executes and keeps a
5//! path set: the files the task read, the directories it listed and the
6//! paths it looked up. A later run keys the task by how those paths are now,
7//! so a change to a file the task never read is still a hit, and a new file
8//! in a directory it listed is a miss. See [`crate::recorded`].
9//!
10//! - `mode`: `"on"`, the default once the field is set, `"record"` or
11//!   `"off"`. With `"record"`, a task is recorded and its recordings kept and
12//!   shared as with `"on"`, but it is keyed by its declared inputs, and the
13//!   run reports what its recordings would have done. Nx's `"warn"` and
14//!   `"error"` are not supported yet.
15//! - `ignoreReads`: workspace globs left out of the path set, for files whose
16//!   changes cannot change the result. One starting `^` names files in the
17//!   projects the task's project depends on, directly or not: see
18//!   [`IgnoredReads`].
19//! - `uncheckedOutputs`: workspace globs naming outputs that a run checked
20//!   against a recorded result leaves out of the comparison, for files a
21//!   tool does not write the same twice, such as coverage hit counts or
22//!   build state that records the builds before it. They are saved and
23//!   restored as any other output.
24
25use std::collections::BTreeSet;
26use std::path::Path;
27
28use anyhow::{Context, Result, bail};
29use qk_config::Workspace;
30use qk_graph::ProjectGraph;
31use qk_taskgraph::Task;
32use serde_json::Value;
33
34use crate::glob::Pattern;
35
36/// The fields a `qk:ultracache` object can set. Others are ignored, as Nx
37/// ignores keys it does not know; `qk doctor` reports them.
38pub const FIELDS: &[&str] = &["mode", "ignoreReads", "uncheckedOutputs"];
39
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct Settings {
42    pub mode: Mode,
43    pub ignore_reads: Vec<String>,
44    pub unchecked_outputs: Vec<String>,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
48pub enum Mode {
49    /// Keyed by its recordings once it has any.
50    #[default]
51    On,
52    /// Recorded, but keyed by its declared inputs.
53    Record,
54}
55
56impl Settings {
57    /// The target's settings when it is recorded, `None` when it is not:
58    /// without the field, or with `mode` `"off"`.
59    pub fn of(task: &Task) -> Result<Option<Self>> {
60        let Some(value) = task.definition.extra.get("qk:ultracache") else {
61            return Ok(None);
62        };
63        let Value::Object(object) = value else {
64            bail!("qk:ultracache must be an object");
65        };
66        let mode = match object.get("mode").map(Value::as_str) {
67            None | Some(Some("on")) => Mode::On,
68            Some(Some("record")) => Mode::Record,
69            Some(Some("off")) => return Ok(None),
70            Some(Some(mode @ ("warn" | "error"))) => {
71                bail!(
72                    "qk:ultracache.mode {mode:?} is not supported yet; use \"on\", \"record\" or \"off\""
73                )
74            }
75            Some(_) => bail!("qk:ultracache.mode must be \"on\", \"record\" or \"off\""),
76        };
77        let ignore_reads = ignored_reads(task)?;
78        let unchecked_outputs: Vec<String> = match object.get("uncheckedOutputs") {
79            None => Vec::new(),
80            Some(value) => serde_json::from_value(value.clone())
81                .context("qk:ultracache.uncheckedOutputs must be an array of globs")?,
82        };
83        for glob in &unchecked_outputs {
84            Pattern::new(glob, false).with_context(|| {
85                format!("qk:ultracache.uncheckedOutputs has an invalid glob {glob:?}")
86            })?;
87        }
88        Ok(Some(Self {
89            mode,
90            ignore_reads,
91            unchecked_outputs,
92        }))
93    }
94
95    /// Whether a run's check leaves the output at `path` out.
96    pub fn unchecked(&self) -> Result<impl Fn(&str) -> bool + use<>> {
97        let patterns = self
98            .unchecked_outputs
99            .iter()
100            .map(|glob| Pattern::new(glob, false))
101            .collect::<Result<Vec<_>>>()?;
102        Ok(move |path: &str| patterns.iter().any(|pattern| pattern.is_match(path)))
103    }
104}
105
106/// The target's `qk:ultracache.ignoreReads`, whatever its mode: the reads
107/// that change nothing the task does, which the sandbox leaves out of its
108/// findings and allows whether or not the task is recorded.
109pub fn ignored_reads(task: &Task) -> Result<Vec<String>> {
110    let Some(Value::Object(object)) = task.definition.extra.get("qk:ultracache") else {
111        return Ok(Vec::new());
112    };
113    let ignore_reads: Vec<String> = match object.get("ignoreReads") {
114        None => Vec::new(),
115        Some(value) => serde_json::from_value(value.clone())
116            .context("qk:ultracache.ignoreReads must be an array of globs")?,
117    };
118    // Checked before the task runs, rather than after it was traced.
119    for glob in &ignore_reads {
120        Pattern::new(glob.strip_prefix('^').unwrap_or(glob), false)
121            .with_context(|| format!("qk:ultracache.ignoreReads has an invalid glob {glob:?}"))?;
122    }
123    Ok(ignore_reads)
124}
125
126/// What a task's `ignoreReads` leaves out, matched against workspace-relative
127/// paths written with `/`.
128///
129/// An entry is a workspace glob, or, starting `^`, a glob within the projects
130/// the task's project depends on, directly or not: it matches a path whose
131/// innermost project is one of them, the glob matched against the path from
132/// that project's root. A file of the task's own project, or of a project
133/// nested in a dependency that it does not depend on, is never matched so.
134/// What a tool reads following imports into a dependency, as an import cycle
135/// rule does, is named that way without naming the task's own files.
136#[derive(Clone, Default)]
137pub struct IgnoredReads {
138    plain: Vec<(String, Pattern)>,
139    dependency: Vec<(String, Pattern)>,
140    /// Matched as plain entries, but not walked: the task's scratch
141    /// directories, which its recordings leave out too.
142    extra: Vec<Pattern>,
143    /// Every project's root, the deepest first, and whether the task's
144    /// project depends on it; only kept with a `^` entry.
145    owners: Vec<(String, bool)>,
146}
147
148impl IgnoredReads {
149    /// `written` as [`ignored_reads`] returns it, for a task of `project`.
150    /// Without `projects`, `^` entries match nothing.
151    pub fn new(
152        written: &[String],
153        workspace: &Workspace,
154        projects: Option<&ProjectGraph>,
155        project: &str,
156    ) -> Result<Self> {
157        let mut ignored = Self::of_globs(written)?;
158        if ignored.dependency.is_empty() {
159            return Ok(ignored);
160        }
161        let depended = projects
162            .map(|projects| depended(projects, project))
163            .unwrap_or_default();
164        ignored.owners = workspace
165            .projects
166            .iter()
167            .map(|(name, config)| (root_of(&config.root), depended.contains(name.as_str())))
168            .collect();
169        // Deepest first; of two projects written with the same root, as `a`
170        // and `./a`, the one not depended on, so neither is ignored, as the
171        // sandbox's report counts neither as a dependency's.
172        ignored.owners.sort_by(|a, b| {
173            b.0.len()
174                .cmp(&a.0.len())
175                .then_with(|| a.0.cmp(&b.0))
176                .then_with(|| a.1.cmp(&b.1))
177        });
178        Ok(ignored)
179    }
180
181    /// Nothing left out.
182    pub const fn none() -> Self {
183        Self {
184            plain: Vec::new(),
185            dependency: Vec::new(),
186            extra: Vec::new(),
187            owners: Vec::new(),
188        }
189    }
190
191    /// Workspace globs alone, as a task outside any project graph has them:
192    /// `^` entries match nothing.
193    pub fn of_globs(written: &[String]) -> Result<Self> {
194        let mut ignored = Self::default();
195        for glob in written {
196            match glob.strip_prefix('^') {
197                Some(within) => ignored
198                    .dependency
199                    .push((within.to_owned(), Pattern::new(within, false)?)),
200                None => ignored
201                    .plain
202                    .push((glob.clone(), Pattern::new(glob, false)?)),
203            }
204        }
205        Ok(ignored)
206    }
207
208    /// These, and `extra` matched as plain entries are.
209    pub fn with_patterns(mut self, extra: impl IntoIterator<Item = Pattern>) -> Self {
210        self.extra.extend(extra);
211        self
212    }
213
214    pub fn is_empty(&self) -> bool {
215        self.plain.is_empty() && self.dependency.is_empty() && self.extra.is_empty()
216    }
217
218    pub fn is_match(&self, path: impl AsRef<Path>) -> bool {
219        let path = path.as_ref();
220        if self.plain.iter().any(|(_, glob)| glob.is_match(path))
221            || self.extra.iter().any(|glob| glob.is_match(path))
222        {
223            return true;
224        }
225        if self.dependency.is_empty() {
226            return false;
227        }
228        let Some(text) = path.to_str() else {
229            return false;
230        };
231        match self.owner(text) {
232            Some((root, true)) => {
233                let within = within(root, text);
234                self.dependency
235                    .iter()
236                    .any(|(_, glob)| glob.is_match(within))
237            }
238            _ => false,
239        }
240    }
241
242    /// The globs a walk for what this matches starts from, as workspace
243    /// globs: the plain entries, and each `^` entry under the root of each
244    /// project it can match in.
245    pub fn walks(&self) -> (Vec<String>, Vec<String>) {
246        let plain = self.plain.iter().map(|(glob, _)| glob.clone()).collect();
247        let mut dependency = Vec::new();
248        for (root, depended) in &self.owners {
249            if !depended {
250                continue;
251            }
252            for (glob, _) in &self.dependency {
253                dependency.push(if root.is_empty() {
254                    glob.clone()
255                } else {
256                    format!("{}/{glob}", crate::glob::escape(root))
257                });
258            }
259        }
260        (plain, dependency)
261    }
262
263    /// Whether a project has its root strictly inside `directory`: a `^`
264    /// entry then does not match all that `directory` holds, since it
265    /// matches nothing in a project not depended on and matches from its own
266    /// root in one that is.
267    pub fn project_within(&self, directory: &str) -> bool {
268        self.owners.iter().any(|(root, _)| {
269            root.len() > directory.len()
270                && (directory.is_empty()
271                    || root
272                        .strip_prefix(directory)
273                        .is_some_and(|rest| rest.starts_with('/')))
274        })
275    }
276
277    /// The roots of the projects `^` entries match in, and of those nested
278    /// in them, or written with the same root, that they do not, each with
279    /// whether it is depended on, for keys that change with what the entries
280    /// match.
281    pub fn scope(&self) -> Vec<(&str, bool)> {
282        self.owners
283            .iter()
284            .filter(|(root, depended)| {
285                *depended
286                    || self.owners.iter().any(|(outer, outer_depended)| {
287                        *outer_depended
288                            && (root == outer
289                                || outer.is_empty()
290                                || root
291                                    .strip_prefix(outer.as_str())
292                                    .is_some_and(|rest| rest.starts_with('/')))
293                    })
294            })
295            .map(|(root, depended)| (root.as_str(), *depended))
296            .collect()
297    }
298
299    /// The innermost project holding `path`, with whether it is depended on.
300    fn owner(&self, path: &str) -> Option<(&str, bool)> {
301        self.owners
302            .iter()
303            .find(|(root, _)| root.is_empty() || within(root, path) != path)
304            .map(|(root, depended)| (root.as_str(), *depended))
305    }
306}
307
308/// `path` from `root`, or `path` itself when `root` does not hold it.
309fn within<'a>(root: &str, path: &'a str) -> &'a str {
310    if root.is_empty() {
311        return path;
312    }
313    path.strip_prefix(root)
314        .and_then(|rest| rest.strip_prefix('/'))
315        .unwrap_or(path)
316}
317
318/// A project root as paths are named: `./libs/lib/` is `libs/lib`, and the
319/// workspace's root project is empty.
320fn root_of(root: &str) -> String {
321    root.split('/')
322        .filter(|part| !part.is_empty() && *part != ".")
323        .collect::<Vec<_>>()
324        .join("/")
325}
326
327/// The projects `project` depends on, directly or not, itself left out.
328fn depended(projects: &ProjectGraph, project: &str) -> BTreeSet<String> {
329    let mut seen = BTreeSet::new();
330    let mut pending = vec![project];
331    while let Some(next) = pending.pop() {
332        for dependency in projects.dependencies.get(next).into_iter().flatten() {
333            if dependency.target != project && seen.insert(dependency.target.clone()) {
334                pending.push(&dependency.target);
335            }
336        }
337    }
338    seen
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn task(value: Value) -> Task {
346        let mut definition: qk_config::Target =
347            serde_json::from_value(serde_json::json!({})).unwrap();
348        definition.extra.insert("qk:ultracache".into(), value);
349        Task {
350            id: "app:build".into(),
351            project: "app".into(),
352            target: "build".into(),
353            configuration: None,
354            args: Vec::new(),
355            definition,
356            dependencies: Default::default(),
357            edges: Default::default(),
358        }
359    }
360
361    /// `app` at `apps/app` depends on `lib`, which holds `nested`, which it
362    /// does not, and on `outer`, whose root holds `app` itself.
363    fn workspace() -> (tempfile::TempDir, Workspace, ProjectGraph) {
364        let temp = tempfile::tempdir().unwrap();
365        let root = temp.path();
366        std::fs::write(root.join("nx.json"), "{}").unwrap();
367        for (directory, name, depends) in [
368            ("apps/app", "app", vec!["lib", "outer", "inner"]),
369            ("apps", "outer", vec![]),
370            ("packages/lib", "lib", vec![]),
371            ("packages/lib/nested", "nested", vec![]),
372            ("packages/lib/src/inner", "inner", vec![]),
373            ("packages/other", "other", vec![]),
374        ] {
375            std::fs::create_dir_all(root.join(directory)).unwrap();
376            std::fs::write(
377                root.join(directory).join("project.json"),
378                serde_json::json!({"name": name, "implicitDependencies": depends}).to_string(),
379            )
380            .unwrap();
381        }
382        let workspace = Workspace::load(root).unwrap();
383        let projects = ProjectGraph::build(&workspace).unwrap();
384        (temp, workspace, projects)
385    }
386
387    #[test]
388    fn a_dependency_entry_matches_only_the_projects_depended_on() {
389        let (_temp, workspace, projects) = workspace();
390        let written = ["^**/*.ts".to_owned(), "tmp/**".to_owned()];
391        let ignored = IgnoredReads::new(&written, &workspace, Some(&projects), "app").unwrap();
392        for (path, matched) in [
393            ("packages/lib/src/a.ts", true),
394            ("packages/lib/src/a.json", false),
395            // A project nested in a dependency that is not one itself
396            ("packages/lib/nested/src/a.ts", false),
397            ("packages/other/a.ts", false),
398            // Its own files, though a dependency's root holds them
399            ("apps/app/src/a.ts", false),
400            ("apps/z.ts", true),
401            ("tmp/x", true),
402        ] {
403            assert_eq!(ignored.is_match(path), matched, "{path}");
404        }
405        let (plain, dependency) = ignored.walks();
406        assert_eq!(plain, ["tmp/**"]);
407        assert_eq!(
408            dependency,
409            [
410                "packages/lib/src/inner/**/*.ts",
411                "packages/lib/**/*.ts",
412                "apps/**/*.ts"
413            ]
414        );
415        assert!(ignored.project_within("packages/lib"));
416        // A dependency nested in one, whose files `^` entries match from its
417        // own root
418        assert!(ignored.project_within("packages/lib/src"));
419        assert!(!ignored.project_within("packages/lib/src/inner"));
420        assert!(!ignored.project_within("packages/lib/nested/src"));
421        assert!(ignored.project_within("apps"));
422        assert_eq!(
423            ignored.scope(),
424            [
425                ("packages/lib/src/inner", true),
426                ("packages/lib/nested", false),
427                ("packages/lib", true),
428                ("apps/app", false),
429                ("apps", true)
430            ]
431        );
432        // Without the graph, or without a `^` entry, nothing depends on it.
433        let alone = IgnoredReads::new(&written, &workspace, None, "app").unwrap();
434        assert!(!alone.is_match("packages/lib/src/a.ts"));
435        assert!(alone.is_match("tmp/x"));
436    }
437
438    #[test]
439    fn reads_the_mode_and_ignored_reads() {
440        let on = Settings::of(&task(serde_json::json!({"ignoreReads": ["tmp/**"]})))
441            .unwrap()
442            .unwrap();
443        assert_eq!(on.ignore_reads, ["tmp/**"]);
444        assert_eq!(on.mode, Mode::On);
445        let record = Settings::of(&task(serde_json::json!({"mode": "record"})))
446            .unwrap()
447            .unwrap();
448        assert_eq!(record.mode, Mode::Record);
449        let unchecked = Settings::of(&task(serde_json::json!({
450            "uncheckedOutputs": ["**/coverage/**", "**/*.tsbuildinfo"]
451        })))
452        .unwrap()
453        .unwrap()
454        .unchecked()
455        .unwrap();
456        assert!(unchecked("app/coverage/lcov.info"));
457        assert!(unchecked("app/dist/.tsbuildinfo"));
458        assert!(!unchecked("app/dist/index.d.ts"));
459        assert!(
460            Settings::of(&task(serde_json::json!({"mode": "off"})))
461                .unwrap()
462                .is_none()
463        );
464        // Unrecorded, its ignored reads still hold for the sandbox.
465        let off = task(serde_json::json!({"mode": "off", "ignoreReads": ["tmp/**"]}));
466        assert_eq!(ignored_reads(&off).unwrap(), ["tmp/**"]);
467        for invalid in [
468            serde_json::json!(true),
469            serde_json::json!({"mode": "warn"}),
470            serde_json::json!({"mode": 1}),
471            serde_json::json!({"ignoreReads": "tmp/**"}),
472            serde_json::json!({"ignoreReads": ["src/[*.log"]}),
473            serde_json::json!({"uncheckedOutputs": "coverage/**"}),
474            serde_json::json!({"uncheckedOutputs": ["src/[*.log"]}),
475        ] {
476            assert!(Settings::of(&task(invalid.clone())).is_err(), "{invalid}");
477        }
478    }
479}