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