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