Skip to main content

qk_input_analysis/
lib.rs

1//! Advisory input analysis. Observations never change task cache keys.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8pub mod strace;
9mod suggestions;
10
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub enum Operation {
14    ReadData,
15    OpenRead,
16    Metadata,
17    ListDirectory,
18    ReadDirectory,
19    ReadLink,
20    Execute,
21    Write,
22    Rename,
23    Delete,
24}
25
26impl Operation {
27    pub fn writes(&self) -> bool {
28        matches!(self, Self::Write | Self::Rename | Self::Delete)
29    }
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub enum Category {
35    Input,
36    Uncovered,
37    OwnOutput,
38    DependencyOutput,
39    WarmState,
40    Generated,
41    /// In `node_modules`, and by an installation the key covers, or by none
42    /// qk can tell.
43    InstalledPackage,
44    /// Read from an installed package the key leaves out: one that its
45    /// `externalDependencies` would have to name.
46    UnkeyedPackage,
47    ResolutionMetadata,
48    StructuredInput,
49    Discovery,
50    GitState,
51    External,
52}
53
54#[derive(Clone, Debug, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct Access {
57    pub process: u32,
58    /// Relative to the workspace, or absolute for external accesses.
59    pub path: String,
60    /// Descriptor target when the backend provides it at access time.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub resolved_path: Option<String>,
63    /// A declared symlink input that covers this observed target.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub keyed_path: Option<String>,
66    /// The installed package an `unkeyedPackage` access read.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub package: Option<String>,
69    pub operation: Operation,
70    /// `success`, `missing`, another errno, or `unknown` for Seatbelt events.
71    pub result: String,
72    pub category: Category,
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Coverage {
78    pub backend: String,
79    pub partial: bool,
80    pub limitations: Vec<String>,
81    pub diagnostics: Vec<String>,
82}
83
84/// Recursive suggestions retain existing files and future files in active trees.
85#[derive(Clone, Debug, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct GlobSuggestion {
88    pub fileset: String,
89    pub observed_files: usize,
90    pub additional_declared_files: usize,
91    #[serde(default)]
92    pub reason: String,
93}
94
95/// Only input declarations are retained; evaluated environment values are absent.
96#[derive(Clone, Debug, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct Declaration {
99    pub target: String,
100    pub inputs: Vec<Value>,
101    pub named_inputs: BTreeMap<String, Vec<Value>>,
102}
103
104#[derive(Clone, Debug, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct TaskAnalysis {
107    pub context: Value,
108    pub outcome: String,
109    pub coverage: Coverage,
110    pub declared_files: BTreeSet<String>,
111    /// Configuration files qk always keys, regardless of target filesets.
112    pub mandatory_files: BTreeSet<String>,
113    pub accesses: Vec<Access>,
114    pub observed_inputs: BTreeSet<String>,
115    pub uncovered_accesses: BTreeSet<String>,
116    pub unobserved_inputs: BTreeSet<String>,
117    /// Successful runs contributing to the union; IDs prevent double counting.
118    pub successful_runs: BTreeSet<String>,
119    pub successful_observations: BTreeSet<String>,
120    /// Exact observed paths for review.
121    pub candidate_filesets: BTreeSet<String>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub declaration: Option<Declaration>,
124    #[serde(default)]
125    pub glob_suggestions: Vec<GlobSuggestion>,
126    /// A project.json fragment, always requiring review across configurations.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub configuration_fragment: Option<Value>,
129    #[serde(default)]
130    pub successful_directories: BTreeSet<String>,
131    #[serde(default)]
132    pub suggestion_notes: Vec<String>,
133    #[serde(default)]
134    pub access_review: AccessReview,
135    /// Installed packages read that the key leaves out, by name, with how
136    /// many of their files were read.
137    #[serde(default)]
138    pub unkeyed_packages: BTreeMap<String, usize>,
139}
140
141/// Review groups never discard the raw uncovered accesses.
142#[derive(Clone, Debug, Default, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct AccessReview {
145    pub content_reads: BTreeSet<String>,
146    pub manifest_reads: BTreeSet<String>,
147    pub toolchain_reads: BTreeSet<String>,
148    #[serde(default)]
149    pub tsconfig_reads: BTreeSet<String>,
150    pub metadata_checks: BTreeSet<String>,
151}
152
153/// What a read of a workspace file outside a task's inputs most likely is,
154/// by its path alone. Input analysis and the sandbox's audit group by it.
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum ReadKind {
157    /// A workspace package's `package.json`, which package managers and
158    /// bundlers read to resolve workspace packages.
159    Manifest,
160    /// A version manager's or package manager's configuration, read as a
161    /// command starts.
162    Toolchain,
163    /// A TypeScript configuration, which changes what tsc and bundlers do,
164    /// so likely an input.
165    TsConfig,
166    /// Anything else, likely content the task uses.
167    Content,
168}
169
170impl ReadKind {
171    pub fn of(path: &str) -> Self {
172        let name = path.rsplit('/').next().unwrap_or(path);
173        if name == "package.json" {
174            Self::Manifest
175        } else if matches!(
176            path,
177            ".tool-versions"
178                | "mise.toml"
179                | ".nvmrc"
180                | ".node-version"
181                | ".npmrc"
182                | ".pnpmfile.cjs"
183                | "rust-toolchain"
184                | "rust-toolchain.toml"
185        ) {
186            Self::Toolchain
187        } else if name.starts_with("tsconfig") && name.ends_with(".json") {
188            Self::TsConfig
189        } else {
190            Self::Content
191        }
192    }
193}
194
195impl TaskAnalysis {
196    pub fn summarize(&mut self, run: &str) {
197        self.observed_inputs = self
198            .accesses
199            .iter()
200            .filter(|event| !event.operation.writes() && event.result != "missing")
201            .filter(|event| event.category == Category::Input)
202            .map(|event| event.keyed_path.as_ref().unwrap_or(&event.path).clone())
203            .collect();
204        self.uncovered_accesses = self
205            .accesses
206            .iter()
207            .filter(|event| !event.operation.writes() && event.category == Category::Uncovered)
208            .map(|event| event.path.clone())
209            .collect();
210        let mut packages: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
211        for event in &self.accesses {
212            if event.category == Category::UnkeyedPackage
213                && let Some(package) = &event.package
214            {
215                packages.entry(package).or_default().insert(&event.path);
216            }
217        }
218        self.unkeyed_packages = packages
219            .into_iter()
220            .map(|(package, paths)| (package.to_owned(), paths.len()))
221            .collect();
222        if self.outcome == "success" && self.coverage.diagnostics.is_empty() {
223            self.successful_runs.insert(run.to_owned());
224            self.successful_observations
225                .extend(self.observed_inputs.clone());
226            self.successful_directories
227                .extend(self.recorded_directories());
228        }
229        self.refresh_candidates();
230    }
231
232    /// Only observations of the same task definition, arguments and platform
233    /// can support a union. Failed/aborted traces don't support narrowing.
234    pub fn merge(&mut self, previous: &Self) -> bool {
235        if self.context != previous.context {
236            return false;
237        }
238        self.successful_runs
239            .extend(previous.successful_runs.clone());
240        self.successful_observations
241            .extend(previous.successful_observations.clone());
242        self.successful_directories
243            .extend(previous.successful_directories.clone());
244        // Version 1 reports predating directory unions still contain these events.
245        if previous.outcome == "success" && previous.coverage.diagnostics.is_empty() {
246            self.successful_directories
247                .extend(previous.recorded_directories());
248        }
249        self.refresh_candidates();
250        true
251    }
252
253    fn recorded_directories(&self) -> BTreeSet<String> {
254        self.accesses
255            .iter()
256            .filter(|event| {
257                event.category == Category::Discovery
258                    && matches!(
259                        event.operation,
260                        Operation::ListDirectory | Operation::ReadDirectory
261                    )
262                    && matches!(event.result.as_str(), "success" | "unknown")
263            })
264            .map(|event| event.path.clone())
265            .collect()
266    }
267
268    fn refresh_candidates(&mut self) {
269        self.unobserved_inputs = if self.successful_runs.is_empty() {
270            BTreeSet::new()
271        } else {
272            &(&self.declared_files - &self.mandatory_files) - &self.successful_observations
273        };
274        // Keep exact observations alongside the broader review suggestions.
275        self.candidate_filesets = self
276            .successful_observations
277            .iter()
278            .filter(|path| self.declared_files.contains(*path))
279            .filter(|path| !self.mandatory_files.contains(*path))
280            .filter(|path| {
281                !path.contains([
282                    '*', '?', '[', ']', '{', '}', '!', '(', ')', '\\', '+', '@', '|', ',',
283                ])
284            })
285            .map(|path| format!("{{workspaceRoot}}/{path}"))
286            .collect();
287        self.access_review = AccessReview::default();
288        let reads: BTreeSet<_> = self
289            .accesses
290            .iter()
291            .filter(|event| {
292                event.category == Category::Uncovered
293                    && !event.operation.writes()
294                    && event.result != "missing"
295            })
296            .filter(|event| event.operation != Operation::Metadata)
297            .map(|event| event.path.clone())
298            .collect();
299        for path in &self.uncovered_accesses {
300            let review = &mut self.access_review;
301            let group = if !reads.contains(path) {
302                &mut review.metadata_checks
303            } else {
304                match ReadKind::of(path) {
305                    ReadKind::Manifest => &mut review.manifest_reads,
306                    ReadKind::Toolchain => &mut review.toolchain_reads,
307                    ReadKind::TsConfig => &mut review.tsconfig_reads,
308                    ReadKind::Content => &mut review.content_reads,
309                }
310            };
311            group.insert(path.clone());
312        }
313        self.suggest();
314    }
315}
316
317#[derive(Clone, Debug, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct Report {
320    pub schema_version: u32,
321    pub run_id: String,
322    pub tasks: BTreeMap<String, TaskAnalysis>,
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn analysis(outcome: &str, path: &str) -> TaskAnalysis {
330        let mut task = TaskAnalysis {
331            context: serde_json::json!({"args": []}),
332            outcome: outcome.into(),
333            coverage: Coverage {
334                backend: "fixture".into(),
335                partial: true,
336                limitations: vec![],
337                diagnostics: vec![],
338            },
339            declared_files: ["a".into(), "b".into()].into(),
340            mandatory_files: BTreeSet::new(),
341            accesses: vec![Access {
342                process: 1,
343                path: path.into(),
344                resolved_path: None,
345                keyed_path: None,
346                package: None,
347                operation: Operation::OpenRead,
348                result: "success".into(),
349                category: Category::Input,
350            }],
351            observed_inputs: BTreeSet::new(),
352            uncovered_accesses: BTreeSet::new(),
353            unobserved_inputs: BTreeSet::new(),
354            successful_runs: BTreeSet::new(),
355            successful_observations: BTreeSet::new(),
356            candidate_filesets: BTreeSet::new(),
357            declaration: None,
358            glob_suggestions: vec![],
359            configuration_fragment: None,
360            successful_directories: BTreeSet::new(),
361            suggestion_notes: vec![],
362            access_review: AccessReview::default(),
363            unkeyed_packages: BTreeMap::new(),
364        };
365        task.summarize(path);
366        task
367    }
368
369    #[test]
370    fn union_only_uses_successful_compatible_recordings_and_deduplicates_runs() {
371        let mut first = analysis("success", "a");
372        let second = analysis("success", "b");
373        assert!(first.merge(&second));
374        assert!(first.merge(&second));
375        assert_eq!(first.successful_runs.len(), 2);
376        assert!(first.unobserved_inputs.is_empty());
377        let mut different = analysis("success", "c");
378        different.context = serde_json::json!({"args": ["--prod"]});
379        assert!(!first.merge(&different));
380        let mut failed = analysis("failed", "b");
381        assert!(failed.unobserved_inputs.is_empty());
382        failed.merge(&analysis("failed", "a"));
383        assert!(failed.successful_runs.is_empty());
384    }
385
386    #[test]
387    fn interrupted_collection_does_not_support_narrowing() {
388        let mut task = analysis("failed", "a");
389        task.outcome = "success".into();
390        task.coverage.diagnostics.push("collector stopped".into());
391        task.summarize("interrupted");
392        assert!(task.candidate_filesets.is_empty());
393    }
394    #[test]
395    fn drafts_expand_local_named_inputs_and_preserve_other_dependencies() {
396        let mut task = analysis("success", "src/a.ts");
397        task.declared_files = [
398            "src/a.ts",
399            "src/b.ts",
400            "src/unused.ts",
401            "src/nested/c.ts",
402            "src/config.json",
403        ]
404        .map(String::from)
405        .into();
406        task.successful_observations
407            .extend(["src/b.ts".into(), "src/config.json".into()]);
408        let retained = vec![
409            serde_json::json!("!{projectRoot}/src/**/*.test.ts"),
410            serde_json::json!({"env": "MODE"}),
411            serde_json::json!({"runtime": "node --version"}),
412            serde_json::json!({"json": "{projectRoot}/settings.json", "fields": ["value"]}),
413            serde_json::json!("^production"),
414            serde_json::json!({"input": "shared", "projects": ["tools"]}),
415            serde_json::json!({"fileset": "{projectRoot}/**/*", "dependencies": true}),
416            serde_json::json!({"dependentTasksOutputFiles": "**/*.d.ts"}),
417        ];
418        let mut local = vec![serde_json::json!({"fileset": "{projectRoot}/**/*"})];
419        local.extend(retained.clone());
420        task.declaration = Some(Declaration {
421            target: "build".into(),
422            inputs: vec![serde_json::json!({"input": "production", "projects": "self"})],
423            named_inputs: [
424                ("production".into(), vec![serde_json::json!("shared")]),
425                ("shared".into(), local),
426            ]
427            .into(),
428        });
429        task.refresh_candidates();
430        assert_eq!(task.glob_suggestions.len(), 1);
431        let glob = &task.glob_suggestions[0];
432        assert_eq!(glob.fileset, "{projectRoot}/src/**/*.{json,ts}");
433        assert_eq!(glob.observed_files, 3);
434        assert_eq!(glob.additional_declared_files, 2);
435        let inputs = task.configuration_fragment.as_ref().unwrap()["targets"]["build"]["inputs"]
436            .as_array()
437            .unwrap();
438        assert!(inputs.contains(&serde_json::json!("{projectRoot}/src/**/*.{json,ts}")));
439        for input in retained {
440            assert!(inputs.contains(&input), "{input}");
441        }
442        assert!(!inputs.contains(&serde_json::json!("{projectRoot}/src/**/*")));
443    }
444
445    #[test]
446    fn fragments_require_safe_observed_paths_and_successful_evidence() {
447        let mut task = analysis("failed", "src/a.ts");
448        task.declaration = Some(Declaration {
449            target: "build".into(),
450            inputs: vec![serde_json::json!("default"), serde_json::json!("^default")],
451            named_inputs: BTreeMap::new(),
452        });
453        task.refresh_candidates();
454        assert!(task.configuration_fragment.is_none());
455        task.successful_runs.insert("prior".into());
456        task.declared_files = ["src/a.ts".into(), "src/a[1].ts".into(), "nx.json".into()].into();
457        task.mandatory_files.insert("nx.json".into());
458        task.successful_observations = task.declared_files.clone();
459        task.refresh_candidates();
460        assert_eq!(
461            task.candidate_filesets,
462            ["{workspaceRoot}/src/a.ts".into()].into()
463        );
464        assert_eq!(
465            task.configuration_fragment.unwrap(),
466            serde_json::json!({"targets": {"build": {"inputs": ["{projectRoot}/src/**/*.ts", "^default"]}}})
467        );
468    }
469
470    #[test]
471    fn old_reports_deserialize_and_merging_rebuilds_drafts_from_current_declarations() {
472        let mut old = serde_json::to_value(analysis("success", "a")).unwrap();
473        for key in ["declaration", "globSuggestions", "configurationFragment"] {
474            old.as_object_mut().unwrap().remove(key);
475        }
476        let old: TaskAnalysis = serde_json::from_value(old).unwrap();
477        let mut current = analysis("success", "b");
478        current.declaration = Some(Declaration {
479            target: "check".into(),
480            inputs: vec![
481                serde_json::json!("default"),
482                serde_json::json!({"env": "CHECK"}),
483            ],
484            named_inputs: BTreeMap::new(),
485        });
486        assert!(current.merge(&old));
487        assert_eq!(
488            current.configuration_fragment.unwrap(),
489            serde_json::json!({"targets": {"check": {"inputs": ["{projectRoot}/a", "{projectRoot}/b", {"env": "CHECK"}]}}})
490        );
491    }
492    #[test]
493    fn specific_named_inputs_are_kept_without_a_fragment_even_with_sparse_observations() {
494        let mut task = analysis("success", "src/a.ts");
495        task.declaration = Some(Declaration {
496            target: "lint".into(),
497            inputs: vec![
498                serde_json::json!("source"),
499                serde_json::json!({"env": "CI"}),
500            ],
501            named_inputs: [(
502                "source".into(),
503                vec![
504                    serde_json::json!("{projectRoot}/**/*.{ts,tsx}"),
505                    serde_json::json!("!{projectRoot}/generated/**/*"),
506                ],
507            )]
508            .into(),
509        });
510        task.refresh_candidates();
511        assert!(task.configuration_fragment.is_none());
512        assert!(task.glob_suggestions.is_empty());
513        assert!(task.suggestion_notes[0].contains("retained"));
514    }
515
516    #[test]
517    fn directory_unions_keep_future_asset_types_and_nested_files() {
518        let mut task = analysis("success", "apps/web/public/logo.svg");
519        task.context = serde_json::json!({"projectRoot": "apps/web"});
520        task.declared_files = [
521            "apps/web/public/logo.svg".into(),
522            "apps/web/public/fonts/a.woff2".into(),
523            "apps/web/src/a.ts".into(),
524            "apps/web/src/b.tsx".into(),
525        ]
526        .into();
527        task.successful_observations = [
528            "apps/web/public/logo.svg".into(),
529            "apps/web/src/a.ts".into(),
530        ]
531        .into();
532        task.declaration = Some(Declaration {
533            target: "build".into(),
534            inputs: vec![serde_json::json!("default"), serde_json::json!("tsconfigs")],
535            named_inputs: [(
536                "tsconfigs".into(),
537                vec![serde_json::json!("{projectRoot}/tsconfig*.json")],
538            )]
539            .into(),
540        });
541        let mut previous = task.clone();
542        previous
543            .successful_directories
544            .insert("apps/web/public".into());
545        task.merge(&previous);
546        let fragment = task.configuration_fragment.as_ref().unwrap();
547        assert_eq!(
548            fragment["targets"]["build"]["inputs"],
549            serde_json::json!([
550                "{projectRoot}/public/**/*",
551                "{projectRoot}/src/**/*.{ts,tsx}",
552                "tsconfigs"
553            ])
554        );
555        let matcher = globset::Glob::new("apps/web/public/**/*")
556            .unwrap()
557            .compile_matcher();
558        assert!(matcher.is_match("apps/web/public/new.json"));
559        assert!(matcher.is_match("apps/web/public/nested/image.png"));
560        assert_eq!(task.glob_suggestions[0].additional_declared_files, 1);
561        assert_eq!(task.glob_suggestions[1].additional_declared_files, 1);
562    }
563
564    #[test]
565    fn rewriting_preserves_exclusion_order_and_opaque_cross_project_inputs() {
566        let declaration = Declaration {
567            target: "build".into(),
568            inputs: vec![
569                serde_json::json!("!{projectRoot}/src/private/**/*"),
570                serde_json::json!("default"),
571                serde_json::json!("!{projectRoot}/src/tests/**/*"),
572                serde_json::json!({"input": "default", "projects": "dependencies"}),
573            ],
574            named_inputs: BTreeMap::new(),
575        };
576        let mut task = analysis("success", "src/a.ts");
577        task.declared_files.insert("src/a.ts".into());
578        task.declaration = Some(declaration);
579        task.refresh_candidates();
580        assert_eq!(
581            task.configuration_fragment.unwrap()["targets"]["build"]["inputs"],
582            serde_json::json!(["!{projectRoot}/src/private/**/*", "{projectRoot}/src/**/*.ts", "!{projectRoot}/src/tests/**/*", {"input": "default", "projects": "dependencies"}])
583        );
584    }
585
586    #[test]
587    fn uncovered_review_groups_reads_without_suppressing_raw_paths() {
588        let mut task = analysis("failed", "unused");
589        for (path, operation) in [
590            ("packages/a/package.json", Operation::ReadData),
591            ("mise.toml", Operation::OpenRead),
592            ("packages/a/tsconfig.lib.json", Operation::ReadData),
593            ("src/hidden.ts", Operation::Metadata),
594            ("src/hidden.ts", Operation::ReadData),
595            ("patches/a.patch", Operation::Metadata),
596        ] {
597            task.accesses.push(Access {
598                process: 1,
599                path: path.into(),
600                resolved_path: None,
601                keyed_path: None,
602                package: None,
603                operation,
604                result: "unknown".into(),
605                category: Category::Uncovered,
606            });
607        }
608        task.summarize("failed");
609        assert_eq!(task.uncovered_accesses.len(), 5);
610        assert_eq!(
611            task.access_review.content_reads,
612            ["src/hidden.ts".into()].into()
613        );
614        assert_eq!(
615            task.access_review.manifest_reads,
616            ["packages/a/package.json".into()].into()
617        );
618        assert_eq!(
619            task.access_review.toolchain_reads,
620            ["mise.toml".into()].into()
621        );
622        assert_eq!(
623            task.access_review.tsconfig_reads,
624            ["packages/a/tsconfig.lib.json".into()].into()
625        );
626        assert_eq!(
627            task.access_review.metadata_checks,
628            ["patches/a.patch".into()].into()
629        );
630        assert!(task.successful_directories.is_empty());
631    }
632    #[test]
633    fn legacy_directory_events_merge_only_from_successful_collection() {
634        let mut current = analysis("success", "public/logo.svg");
635        let mut old = current.clone();
636        old.accesses.push(Access {
637            process: 1,
638            path: "public".into(),
639            resolved_path: None,
640            keyed_path: None,
641            package: None,
642            operation: Operation::ReadDirectory,
643            result: "unknown".into(),
644            category: Category::Discovery,
645        });
646        assert!(current.merge(&old));
647        assert!(current.successful_directories.contains("public"));
648        let mut failed = old.clone();
649        failed.outcome = "cancelled".into();
650        failed.successful_directories.clear();
651        let mut clean = analysis("success", "public/logo.svg");
652        clean.merge(&failed);
653        assert!(clean.successful_directories.is_empty());
654        old.coverage.diagnostics.push("collector stopped".into());
655        clean.merge(&old);
656        assert!(clean.successful_directories.is_empty());
657    }
658}