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