1use 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 InstalledPackage,
44 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 pub path: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub resolved_path: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub keyed_path: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub package: Option<String>,
69 pub operation: Operation,
70 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#[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#[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 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 pub successful_runs: BTreeSet<String>,
119 pub successful_observations: BTreeSet<String>,
120 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 #[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 #[serde(default)]
138 pub unkeyed_packages: BTreeMap<String, usize>,
139 #[serde(default)]
144 pub names_external_dependencies: bool,
145}
146
147#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub enum ReadKind {
163 Manifest,
166 Toolchain,
169 TsConfig,
172 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 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 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 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}