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