1use 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,
43 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 pub path: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub resolved_path: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub keyed_path: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub package: Option<String>,
68 pub operation: Operation,
69 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#[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#[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 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 pub successful_runs: BTreeSet<String>,
118 pub successful_observations: BTreeSet<String>,
119 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 #[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 #[serde(default)]
137 pub unkeyed_packages: BTreeMap<String, usize>,
138}
139
140#[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 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 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 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}