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    pub metadata_checks: BTreeSet<String>,
148}
149
150impl TaskAnalysis {
151    pub fn summarize(&mut self, run: &str) {
152        self.observed_inputs = self
153            .accesses
154            .iter()
155            .filter(|event| !event.operation.writes() && event.result != "missing")
156            .filter(|event| event.category == Category::Input)
157            .map(|event| event.keyed_path.as_ref().unwrap_or(&event.path).clone())
158            .collect();
159        self.uncovered_accesses = self
160            .accesses
161            .iter()
162            .filter(|event| !event.operation.writes() && event.category == Category::Uncovered)
163            .map(|event| event.path.clone())
164            .collect();
165        let mut packages: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
166        for event in &self.accesses {
167            if event.category == Category::UnkeyedPackage
168                && let Some(package) = &event.package
169            {
170                packages.entry(package).or_default().insert(&event.path);
171            }
172        }
173        self.unkeyed_packages = packages
174            .into_iter()
175            .map(|(package, paths)| (package.to_owned(), paths.len()))
176            .collect();
177        if self.outcome == "success" && self.coverage.diagnostics.is_empty() {
178            self.successful_runs.insert(run.to_owned());
179            self.successful_observations
180                .extend(self.observed_inputs.clone());
181            self.successful_directories
182                .extend(self.recorded_directories());
183        }
184        self.refresh_candidates();
185    }
186
187    /// Only observations of the same task definition, arguments and platform
188    /// can support a union. Failed/aborted traces don't support narrowing.
189    pub fn merge(&mut self, previous: &Self) -> bool {
190        if self.context != previous.context {
191            return false;
192        }
193        self.successful_runs
194            .extend(previous.successful_runs.clone());
195        self.successful_observations
196            .extend(previous.successful_observations.clone());
197        self.successful_directories
198            .extend(previous.successful_directories.clone());
199        // Version 1 reports predating directory unions still contain these events.
200        if previous.outcome == "success" && previous.coverage.diagnostics.is_empty() {
201            self.successful_directories
202                .extend(previous.recorded_directories());
203        }
204        self.refresh_candidates();
205        true
206    }
207
208    fn recorded_directories(&self) -> BTreeSet<String> {
209        self.accesses
210            .iter()
211            .filter(|event| {
212                event.category == Category::Discovery
213                    && matches!(
214                        event.operation,
215                        Operation::ListDirectory | Operation::ReadDirectory
216                    )
217                    && matches!(event.result.as_str(), "success" | "unknown")
218            })
219            .map(|event| event.path.clone())
220            .collect()
221    }
222
223    fn refresh_candidates(&mut self) {
224        self.unobserved_inputs = if self.successful_runs.is_empty() {
225            BTreeSet::new()
226        } else {
227            &(&self.declared_files - &self.mandatory_files) - &self.successful_observations
228        };
229        // Keep exact observations alongside the broader review suggestions.
230        self.candidate_filesets = self
231            .successful_observations
232            .iter()
233            .filter(|path| self.declared_files.contains(*path))
234            .filter(|path| !self.mandatory_files.contains(*path))
235            .filter(|path| {
236                !path.contains([
237                    '*', '?', '[', ']', '{', '}', '!', '(', ')', '\\', '+', '@', '|', ',',
238                ])
239            })
240            .map(|path| format!("{{workspaceRoot}}/{path}"))
241            .collect();
242        self.access_review = AccessReview::default();
243        let reads: BTreeSet<_> = self
244            .accesses
245            .iter()
246            .filter(|event| {
247                event.category == Category::Uncovered
248                    && !event.operation.writes()
249                    && event.result != "missing"
250            })
251            .filter(|event| event.operation != Operation::Metadata)
252            .map(|event| event.path.clone())
253            .collect();
254        for path in &self.uncovered_accesses {
255            if !reads.contains(path) {
256                self.access_review.metadata_checks.insert(path.clone());
257            } else if path.ends_with("/package.json") || path == "package.json" {
258                self.access_review.manifest_reads.insert(path.clone());
259            } else if matches!(
260                path.as_str(),
261                ".tool-versions"
262                    | "mise.toml"
263                    | ".nvmrc"
264                    | ".node-version"
265                    | ".npmrc"
266                    | ".pnpmfile.cjs"
267                    | "rust-toolchain"
268                    | "rust-toolchain.toml"
269            ) {
270                self.access_review.toolchain_reads.insert(path.clone());
271            } else {
272                self.access_review.content_reads.insert(path.clone());
273            }
274        }
275        self.suggest();
276    }
277}
278
279#[derive(Clone, Debug, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct Report {
282    pub schema_version: u32,
283    pub run_id: String,
284    pub tasks: BTreeMap<String, TaskAnalysis>,
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn analysis(outcome: &str, path: &str) -> TaskAnalysis {
292        let mut task = TaskAnalysis {
293            context: serde_json::json!({"args": []}),
294            outcome: outcome.into(),
295            coverage: Coverage {
296                backend: "fixture".into(),
297                partial: true,
298                limitations: vec![],
299                diagnostics: vec![],
300            },
301            declared_files: ["a".into(), "b".into()].into(),
302            mandatory_files: BTreeSet::new(),
303            accesses: vec![Access {
304                process: 1,
305                path: path.into(),
306                resolved_path: None,
307                keyed_path: None,
308                package: None,
309                operation: Operation::OpenRead,
310                result: "success".into(),
311                category: Category::Input,
312            }],
313            observed_inputs: BTreeSet::new(),
314            uncovered_accesses: BTreeSet::new(),
315            unobserved_inputs: BTreeSet::new(),
316            successful_runs: BTreeSet::new(),
317            successful_observations: BTreeSet::new(),
318            candidate_filesets: BTreeSet::new(),
319            declaration: None,
320            glob_suggestions: vec![],
321            configuration_fragment: None,
322            successful_directories: BTreeSet::new(),
323            suggestion_notes: vec![],
324            access_review: AccessReview::default(),
325            unkeyed_packages: BTreeMap::new(),
326        };
327        task.summarize(path);
328        task
329    }
330
331    #[test]
332    fn union_only_uses_successful_compatible_recordings_and_deduplicates_runs() {
333        let mut first = analysis("success", "a");
334        let second = analysis("success", "b");
335        assert!(first.merge(&second));
336        assert!(first.merge(&second));
337        assert_eq!(first.successful_runs.len(), 2);
338        assert!(first.unobserved_inputs.is_empty());
339        let mut different = analysis("success", "c");
340        different.context = serde_json::json!({"args": ["--prod"]});
341        assert!(!first.merge(&different));
342        let mut failed = analysis("failed", "b");
343        assert!(failed.unobserved_inputs.is_empty());
344        failed.merge(&analysis("failed", "a"));
345        assert!(failed.successful_runs.is_empty());
346    }
347
348    #[test]
349    fn interrupted_collection_does_not_support_narrowing() {
350        let mut task = analysis("failed", "a");
351        task.outcome = "success".into();
352        task.coverage.diagnostics.push("collector stopped".into());
353        task.summarize("interrupted");
354        assert!(task.candidate_filesets.is_empty());
355    }
356    #[test]
357    fn drafts_expand_local_named_inputs_and_preserve_other_dependencies() {
358        let mut task = analysis("success", "src/a.ts");
359        task.declared_files = [
360            "src/a.ts",
361            "src/b.ts",
362            "src/unused.ts",
363            "src/nested/c.ts",
364            "src/config.json",
365        ]
366        .map(String::from)
367        .into();
368        task.successful_observations
369            .extend(["src/b.ts".into(), "src/config.json".into()]);
370        let retained = vec![
371            serde_json::json!("!{projectRoot}/src/**/*.test.ts"),
372            serde_json::json!({"env": "MODE"}),
373            serde_json::json!({"runtime": "node --version"}),
374            serde_json::json!({"json": "{projectRoot}/settings.json", "fields": ["value"]}),
375            serde_json::json!("^production"),
376            serde_json::json!({"input": "shared", "projects": ["tools"]}),
377            serde_json::json!({"fileset": "{projectRoot}/**/*", "dependencies": true}),
378            serde_json::json!({"dependentTasksOutputFiles": "**/*.d.ts"}),
379        ];
380        let mut local = vec![serde_json::json!({"fileset": "{projectRoot}/**/*"})];
381        local.extend(retained.clone());
382        task.declaration = Some(Declaration {
383            target: "build".into(),
384            inputs: vec![serde_json::json!({"input": "production", "projects": "self"})],
385            named_inputs: [
386                ("production".into(), vec![serde_json::json!("shared")]),
387                ("shared".into(), local),
388            ]
389            .into(),
390        });
391        task.refresh_candidates();
392        assert_eq!(task.glob_suggestions.len(), 1);
393        let glob = &task.glob_suggestions[0];
394        assert_eq!(glob.fileset, "{projectRoot}/src/**/*.{json,ts}");
395        assert_eq!(glob.observed_files, 3);
396        assert_eq!(glob.additional_declared_files, 2);
397        let inputs = task.configuration_fragment.as_ref().unwrap()["targets"]["build"]["inputs"]
398            .as_array()
399            .unwrap();
400        assert!(inputs.contains(&serde_json::json!("{projectRoot}/src/**/*.{json,ts}")));
401        for input in retained {
402            assert!(inputs.contains(&input), "{input}");
403        }
404        assert!(!inputs.contains(&serde_json::json!("{projectRoot}/src/**/*")));
405    }
406
407    #[test]
408    fn fragments_require_safe_observed_paths_and_successful_evidence() {
409        let mut task = analysis("failed", "src/a.ts");
410        task.declaration = Some(Declaration {
411            target: "build".into(),
412            inputs: vec![serde_json::json!("default"), serde_json::json!("^default")],
413            named_inputs: BTreeMap::new(),
414        });
415        task.refresh_candidates();
416        assert!(task.configuration_fragment.is_none());
417        task.successful_runs.insert("prior".into());
418        task.declared_files = ["src/a.ts".into(), "src/a[1].ts".into(), "nx.json".into()].into();
419        task.mandatory_files.insert("nx.json".into());
420        task.successful_observations = task.declared_files.clone();
421        task.refresh_candidates();
422        assert_eq!(
423            task.candidate_filesets,
424            ["{workspaceRoot}/src/a.ts".into()].into()
425        );
426        assert_eq!(
427            task.configuration_fragment.unwrap(),
428            serde_json::json!({"targets": {"build": {"inputs": ["{projectRoot}/src/**/*.ts", "^default"]}}})
429        );
430    }
431
432    #[test]
433    fn old_reports_deserialize_and_merging_rebuilds_drafts_from_current_declarations() {
434        let mut old = serde_json::to_value(analysis("success", "a")).unwrap();
435        for key in ["declaration", "globSuggestions", "configurationFragment"] {
436            old.as_object_mut().unwrap().remove(key);
437        }
438        let old: TaskAnalysis = serde_json::from_value(old).unwrap();
439        let mut current = analysis("success", "b");
440        current.declaration = Some(Declaration {
441            target: "check".into(),
442            inputs: vec![
443                serde_json::json!("default"),
444                serde_json::json!({"env": "CHECK"}),
445            ],
446            named_inputs: BTreeMap::new(),
447        });
448        assert!(current.merge(&old));
449        assert_eq!(
450            current.configuration_fragment.unwrap(),
451            serde_json::json!({"targets": {"check": {"inputs": ["{projectRoot}/a", "{projectRoot}/b", {"env": "CHECK"}]}}})
452        );
453    }
454    #[test]
455    fn specific_named_inputs_are_kept_without_a_fragment_even_with_sparse_observations() {
456        let mut task = analysis("success", "src/a.ts");
457        task.declaration = Some(Declaration {
458            target: "lint".into(),
459            inputs: vec![
460                serde_json::json!("source"),
461                serde_json::json!({"env": "CI"}),
462            ],
463            named_inputs: [(
464                "source".into(),
465                vec![
466                    serde_json::json!("{projectRoot}/**/*.{ts,tsx}"),
467                    serde_json::json!("!{projectRoot}/generated/**/*"),
468                ],
469            )]
470            .into(),
471        });
472        task.refresh_candidates();
473        assert!(task.configuration_fragment.is_none());
474        assert!(task.glob_suggestions.is_empty());
475        assert!(task.suggestion_notes[0].contains("retained"));
476    }
477
478    #[test]
479    fn directory_unions_keep_future_asset_types_and_nested_files() {
480        let mut task = analysis("success", "apps/web/public/logo.svg");
481        task.context = serde_json::json!({"projectRoot": "apps/web"});
482        task.declared_files = [
483            "apps/web/public/logo.svg".into(),
484            "apps/web/public/fonts/a.woff2".into(),
485            "apps/web/src/a.ts".into(),
486            "apps/web/src/b.tsx".into(),
487        ]
488        .into();
489        task.successful_observations = [
490            "apps/web/public/logo.svg".into(),
491            "apps/web/src/a.ts".into(),
492        ]
493        .into();
494        task.declaration = Some(Declaration {
495            target: "build".into(),
496            inputs: vec![serde_json::json!("default"), serde_json::json!("tsconfigs")],
497            named_inputs: [(
498                "tsconfigs".into(),
499                vec![serde_json::json!("{projectRoot}/tsconfig*.json")],
500            )]
501            .into(),
502        });
503        let mut previous = task.clone();
504        previous
505            .successful_directories
506            .insert("apps/web/public".into());
507        task.merge(&previous);
508        let fragment = task.configuration_fragment.as_ref().unwrap();
509        assert_eq!(
510            fragment["targets"]["build"]["inputs"],
511            serde_json::json!([
512                "{projectRoot}/public/**/*",
513                "{projectRoot}/src/**/*.{ts,tsx}",
514                "tsconfigs"
515            ])
516        );
517        let matcher = globset::Glob::new("apps/web/public/**/*")
518            .unwrap()
519            .compile_matcher();
520        assert!(matcher.is_match("apps/web/public/new.json"));
521        assert!(matcher.is_match("apps/web/public/nested/image.png"));
522        assert_eq!(task.glob_suggestions[0].additional_declared_files, 1);
523        assert_eq!(task.glob_suggestions[1].additional_declared_files, 1);
524    }
525
526    #[test]
527    fn rewriting_preserves_exclusion_order_and_opaque_cross_project_inputs() {
528        let declaration = Declaration {
529            target: "build".into(),
530            inputs: vec![
531                serde_json::json!("!{projectRoot}/src/private/**/*"),
532                serde_json::json!("default"),
533                serde_json::json!("!{projectRoot}/src/tests/**/*"),
534                serde_json::json!({"input": "default", "projects": "dependencies"}),
535            ],
536            named_inputs: BTreeMap::new(),
537        };
538        let mut task = analysis("success", "src/a.ts");
539        task.declared_files.insert("src/a.ts".into());
540        task.declaration = Some(declaration);
541        task.refresh_candidates();
542        assert_eq!(
543            task.configuration_fragment.unwrap()["targets"]["build"]["inputs"],
544            serde_json::json!(["!{projectRoot}/src/private/**/*", "{projectRoot}/src/**/*.ts", "!{projectRoot}/src/tests/**/*", {"input": "default", "projects": "dependencies"}])
545        );
546    }
547
548    #[test]
549    fn uncovered_review_groups_reads_without_suppressing_raw_paths() {
550        let mut task = analysis("failed", "unused");
551        for (path, operation) in [
552            ("packages/a/package.json", Operation::ReadData),
553            ("mise.toml", Operation::OpenRead),
554            ("src/hidden.ts", Operation::Metadata),
555            ("src/hidden.ts", Operation::ReadData),
556            ("patches/a.patch", Operation::Metadata),
557        ] {
558            task.accesses.push(Access {
559                process: 1,
560                path: path.into(),
561                resolved_path: None,
562                keyed_path: None,
563                package: None,
564                operation,
565                result: "unknown".into(),
566                category: Category::Uncovered,
567            });
568        }
569        task.summarize("failed");
570        assert_eq!(task.uncovered_accesses.len(), 4);
571        assert_eq!(
572            task.access_review.content_reads,
573            ["src/hidden.ts".into()].into()
574        );
575        assert_eq!(
576            task.access_review.manifest_reads,
577            ["packages/a/package.json".into()].into()
578        );
579        assert_eq!(
580            task.access_review.toolchain_reads,
581            ["mise.toml".into()].into()
582        );
583        assert_eq!(
584            task.access_review.metadata_checks,
585            ["patches/a.patch".into()].into()
586        );
587        assert!(task.successful_directories.is_empty());
588    }
589    #[test]
590    fn legacy_directory_events_merge_only_from_successful_collection() {
591        let mut current = analysis("success", "public/logo.svg");
592        let mut old = current.clone();
593        old.accesses.push(Access {
594            process: 1,
595            path: "public".into(),
596            resolved_path: None,
597            keyed_path: None,
598            package: None,
599            operation: Operation::ReadDirectory,
600            result: "unknown".into(),
601            category: Category::Discovery,
602        });
603        assert!(current.merge(&old));
604        assert!(current.successful_directories.contains("public"));
605        let mut failed = old.clone();
606        failed.outcome = "cancelled".into();
607        failed.successful_directories.clear();
608        let mut clean = analysis("success", "public/logo.svg");
609        clean.merge(&failed);
610        assert!(clean.successful_directories.is_empty());
611        old.coverage.diagnostics.push("collector stopped".into());
612        clean.merge(&old);
613        assert!(clean.successful_directories.is_empty());
614    }
615}